Playtest #6 follow-up (v2.5.1): leader HP review — 直接乘算, one debuff instance, 999 layer cap, 限伤; elite to the board; 假想敌:胄 kit
- co-op leaders died within seconds: leader HP is the official data (40 values pinned); bond / strategy / item / 机变 / 特质 "+X%" bonuses now add up (PRTS 直接乘算) instead of compounding; a same-named enemy debuff keeps one (strongest) instance per target (奥术 across a pair field); leader parts pass damage 1:1; fixed co-op pool (the user's call) - official limits read from the client: each bond stops at 999 layers (MAX_GARRISON_STACK); a leader hit of 300000+ in boss / hidden battles is cancelled (MAX_BATTLE_DAMAGE, 限伤) — shared/constants.js switches, research 11 - a merge that consumes a deployed copy puts the elite on that tile and facing (user-confirmed); gold target highlight, promotion effect - 假想敌:胄 per PRTS: one shell, shells within range, any drone death costs 2 % max HP, 剑/锤 sorties and 瘫痪 - DESIGN §20.10–§20.14; 2842 unit tests, all 28 browser suites green; Chrome = Node on boss fields
This commit is contained in:
1 parent
6464e4b46a
commit
620f88c0d8
81 files changed
+4194
-447
No files matched your search
+39
-11
@@ -119,7 +119,7 @@
|
||||
// human is left at all the match ends ('abandoned'); when nobody alive is left it ends as 'eliminated'.
|
||||
|
||||
import { C2S, unitStatsEntry } from '../../shared/protocol.js';
|
||||
import { PHASE, ERR, EMOTES, EMOTE_COOLDOWN_MS, GEO, modeIdFor } from '../../shared/constants.js';
|
||||
import { PHASE, ERR, EMOTES, EMOTE_COOLDOWN_MS, GEO, modeIdFor, layerGainRoom } from '../../shared/constants.js';
|
||||
import { Battle } from '../sim/Battle.js';
|
||||
import { DataSource } from '../sim/simdata.js';
|
||||
import { createRng, deriveSeed } from '../sim/rng.js';
|
||||
@@ -174,7 +174,8 @@ const BOSS_RESULT_GRACE_MS = 6000;
|
||||
* pool per BOSS_MIN_CLEAR_GS game seconds (20 % of the pool per game second; the balance model's fastest mean kills
|
||||
* take ≈ 18–20 s at ≈ 5 %/s per field, docs/BALANCE.md §3), the credited LP cost ≤ BOSS_LP_BURST + BOSS_LP_PER_GS per
|
||||
* game second (a leader's "扣除所有目标生命" comes after ≥ 200 s). Reports are cumulative: what exceeds the budget is
|
||||
* credited later as the budget grows (the boss clock re-applies the latest report), never lost.
|
||||
* credited later as the budget grows (the boss clock re-applies the latest report), never lost — a 'cleared' b.result
|
||||
* whose report covers the pool waits for it too (`heldResult`; 999-layer kills take 2–4 game s), it is not handed over.
|
||||
*/
|
||||
const BOSS_MIN_CLEAR_GS = 5;
|
||||
const BOSS_LP_BURST = 10;
|
||||
@@ -1909,8 +1910,9 @@ export class Match {
|
||||
progress: { gt: 0, killed: 0, total, leaks: 0, done: false }, lastProgressAt: this.sched.now(),
|
||||
bossAcked: 0, bossBy: {}, lpAcked: 0, lpCum: 0, deadlineTimer: null, doneTimer: null, waitTimer: null,
|
||||
// boss fields: the latest client reports (re-credited as the plausibility budget grows), the server run's
|
||||
// CreditPool, humans demoted for an implausible result (never the authority of this field again)
|
||||
bossReported: null, lpReported: 0, credit: null, demoted: new Set(),
|
||||
// CreditPool, humans demoted for an implausible result (never the authority of this field again), a 'cleared'
|
||||
// b.result waiting for the budget to credit the pool it emptied (`heldResult`, _onResult)
|
||||
bossReported: null, lpReported: 0, credit: null, demoted: new Set(), heldResult: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2306,7 +2308,7 @@ export class Match {
|
||||
_onResult(ps, msg) {
|
||||
if (!this.clientCombat) return fail(ERR.WRONG_PHASE, 'server-run combat');
|
||||
const f = this._fieldByBattle(msg.battleId);
|
||||
if (!f || f.done || f.mode !== 'client' || f.authority !== ps.playerId) return OK;
|
||||
if (!f || f.done || f.heldResult || f.mode !== 'client' || f.authority !== ps.playerId) return OK;
|
||||
const bossLike = f.kind === 'boss' || f.kind === 'hidden';
|
||||
const v = validateClientResult(f.spec, msg.result, { gd: this.gd });
|
||||
if (!v.ok) {
|
||||
@@ -2334,12 +2336,23 @@ export class Match {
|
||||
// the pair's fight (and, with every field done, end the Final Assault as a defeat). The field is handed to the
|
||||
// partner's replica or the server instead, and the sender never reports it again.
|
||||
if (!this._finalEnding && !(pool && pool.hp <= 0)) {
|
||||
// 'cleared' while the budget still holds part of the client's cumulative report back: when that report covers
|
||||
// what the pool holds, the client emptied the pool as it saw it, only faster than BOSS_MIN_CLEAR_GS lets the
|
||||
// server credit (999-layer boards kill a 绝境 leader in 2–4 game s; official "boss一秒死", DESIGN §20.10). The
|
||||
// result waits: the boss clock credits the report as the budget grows and the pool's end (_endFinal) completes
|
||||
// the field with it — no takeover, nothing credited faster than the budget (a forged report gains nothing a
|
||||
// b.progress could not already get). The 1 HP slack is the pool's dust floor (BOSS_POOL_MIN_HP).
|
||||
const reported = Math.max(sum, f.bossReported ? f.bossReported.cum : 0);
|
||||
if (result.reason === 'cleared' && pool && reported - f.bossAcked >= pool.hp - 1) {
|
||||
f.heldResult = result;
|
||||
return OK;
|
||||
}
|
||||
this.verifyStats.rejected++;
|
||||
this.log.warn?.(`[match ${this.roomCode}] ${f.fieldId}: implausible boss result from ${ps.playerId} (${result.reason} while the pool holds ${pool ? Math.round(pool.hp) : '?'}) — handed over`);
|
||||
this._bossHandover(f, 'invalid', { demote: true });
|
||||
return OK;
|
||||
}
|
||||
for (const pid of f.players) if (result.perPlayer[pid]) result.perPlayer[pid].bossDamage = Math.max(result.perPlayer[pid].bossDamage || 0, f.bossBy[pid] || 0);
|
||||
this._bossResultDamage(f, result);
|
||||
} else {
|
||||
result = this._verifyResult(f, result);
|
||||
}
|
||||
@@ -2350,6 +2363,11 @@ export class Match {
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** A boss field's accepted client result: each player's bossDamage is at least what the server credited them. */
|
||||
_bossResultDamage(f, result) {
|
||||
for (const pid of f.players) if (result.perPlayer[pid]) result.perPlayer[pid].bossDamage = Math.max(result.perPlayer[pid].bossDamage || 0, f.bossBy[pid] || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* SP_VERIFY: re-simulate an accepted client result ('all': now, the server's result wins on a mismatch; 'sample':
|
||||
* ~1 battle in 8 in a later callback, mismatches are only logged).
|
||||
@@ -2555,7 +2573,7 @@ export class Match {
|
||||
* its partner (a replica already running) takes over, else the server.
|
||||
*/
|
||||
_bossHandover(f, why, { demote = false } = {}) {
|
||||
if (f.done || f.mode !== 'client') return;
|
||||
if (f.done || f.mode !== 'client' || f.heldResult) return; // a held 'cleared' result is in: nothing left to run
|
||||
const prev = f.authority;
|
||||
if (demote && prev) f.demoted.add(prev);
|
||||
const next = this._authorityFor(f, prev);
|
||||
@@ -2581,7 +2599,7 @@ export class Match {
|
||||
if (f.bossReported && f.bossReported.cum > f.bossAcked) this._creditBoss(f, f.bossReported.cum, f.bossReported.by);
|
||||
if (f.lpReported > f.lpAcked) this._creditLp(f, f.lpReported, true);
|
||||
}
|
||||
if (f.cc && !f.done && f.mode === 'client' && now - f.lastProgressAt > BOSS_SILENCE_MS) {
|
||||
if (f.cc && !f.done && f.mode === 'client' && !f.heldResult && now - f.lastProgressAt > BOSS_SILENCE_MS) {
|
||||
this.log.info?.(`[match ${this.roomCode}] ${f.fieldId}: no progress from ${f.authority} — handing the field over`);
|
||||
this._bossHandover(f, 'silent');
|
||||
}
|
||||
@@ -2615,6 +2633,13 @@ export class Match {
|
||||
continue; // _fieldDone below (after every field was told)
|
||||
}
|
||||
for (const pid of this._humansShowing(f)) this.sendTo(pid, { t: 'b.end', battleId: f.battleId, fieldId: f.fieldId, reason });
|
||||
if (f.heldResult) {
|
||||
// its authority already reported 'cleared' (_onResult): that result completes the field
|
||||
f.result = f.heldResult;
|
||||
this._bossResultDamage(f, f.result);
|
||||
f.resultSource = 'client';
|
||||
continue; // _fieldDone below
|
||||
}
|
||||
f.waitTimer = this.later(this.scaled(BOSS_RESULT_GRACE_MS), () => {
|
||||
f.waitTimer = null;
|
||||
if (f.done) return;
|
||||
@@ -2623,7 +2648,7 @@ export class Match {
|
||||
});
|
||||
}
|
||||
if (this.pacer) { this.pacer.stop(); this.pacer = null; }
|
||||
for (const f of this.fields) if (f.cc && !f.done && f.mode === 'server') this._fieldDone(f);
|
||||
for (const f of this.fields) if (f.cc && !f.done && (f.mode === 'server' || f.heldResult)) this._fieldDone(f);
|
||||
}
|
||||
|
||||
/** b.pool { hp, max, teamLp, acked } to everyone (≤ 4 Hz; `force` skips the dedupe, never the rate). */
|
||||
@@ -2689,11 +2714,14 @@ export class Match {
|
||||
if (coins > 0) { ps.pendingFunds += coins; ps.stats.fundsGained += coins; }
|
||||
for (const b of ps.bounties) b.roundsLeft--;
|
||||
ps.bounties = ps.bounties.filter((b) => b.roundsLeft > 0);
|
||||
// IN_BATTLE layer gains (normal battles only)
|
||||
// IN_BATTLE layer gains (normal battles only), at most the room left under BOND_LAYER_CAP (999, as the battle's
|
||||
// live copy: Battle.addLayers); a bond at the cap gains nothing and dispatches nothing
|
||||
for (const [bondId, n] of Object.entries(r.layerGains || {})) {
|
||||
if (!this.gd.bond(bondId) || !(n > 0)) continue;
|
||||
const before = ps.layers[bondId] || 0;
|
||||
ps.layers[bondId] = before + Math.floor(n);
|
||||
const add = layerGainRoom(before, Math.floor(n));
|
||||
if (!(add > 0)) continue;
|
||||
ps.layers[bondId] = before + add;
|
||||
this.dispatch(ps, 'onLayers', { bondId, from: before, to: ps.layers[bondId], reason: 'battle' });
|
||||
}
|
||||
this._charDamageTickers(ps, r);
|
||||
|
||||
+47
-33
@@ -21,13 +21,15 @@
|
||||
// * Shop: per-level chess slots + item slot, copy-weighted rolls from the SHARED pool (pool.js); refresh 1 (free
|
||||
// refreshes first), one toggle freezes all unsold slots until the next round start (a manual refresh while frozen
|
||||
// rerolls everything and the new slots stay frozen), level-up price = base − rounds elapsed (floor 0).
|
||||
// * Merge: 3 normal copies (风丸 2) on board/hand/temp → 1 elite to the hand; equipment returns to the hand; a
|
||||
// * Merge: 3 normal copies (风丸 2) on board/hand/temp → 1 elite; equipment returns to the hand; a
|
||||
// reward offer of 3 different free chess of tier min(level+1, 6) is queued (never one operator twice — user
|
||||
// playtest #6 item 19; a short tier tops up from the tier below; pick 1, expires at prep end; an offer earned
|
||||
// after the prep — SETTLE / Final Assault effects — is kept for the next prep). The elite goes to the hand
|
||||
// (research 01 A1 "1 elite goes to the hand, not to a board tile"), overflow temp — also outside PREP (a
|
||||
// SETTLE merge's elite waits in temp for the next prep); only with the hand and temp both full does it take a
|
||||
// freed board tile of a consumed copy.
|
||||
// after the prep — SETTLE / Final Assault effects — is kept for the next prep). Where the elite goes (PRTS
|
||||
// 卫戍协议/帮助 "发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员,则发送至作战区对应位置)", user
|
||||
// playtest #6 follow-up): when a consumed copy stood on the board, onto that copy's tile with its facing (of several,
|
||||
// the one that deploys first — board.js mergeTile, [ASSUMED]); it replaces a deployed copy, so the deploy count
|
||||
// never grows, and it gets its own summon stack (grantTokensFor). Otherwise to the hand, overflow temp — also
|
||||
// outside PREP (a SETTLE merge's elite waits in temp for the next prep).
|
||||
// * Items: equip max 2 (a 3rd replaces the equipped item the player picks — g.equip replaceUid, the oldest when
|
||||
// absent; equipped items are otherwise locked: g.destroy refuses them),
|
||||
// 2 identical normal items (hand/temp/equipped) merge into the golden item in the hand, items are never sold
|
||||
@@ -35,7 +37,8 @@
|
||||
// * Tokens (PRTS 卫戍协议/帮助 §战斗部署, user playtest #6): placing an owner with manually deployable summons
|
||||
// (tokens.json `placeable`: 赫默's 医疗探机 and 巫恋's 诅咒娃娃 with their S2, 凯瑟琳's 爬行号·防护单元, 海嗣 / 狼群 /
|
||||
// 流形) sends one stack (deployLimit copies — 凯瑟琳 2) to the hand, placed by hand like any piece (no deploy slot);
|
||||
// withdrawing/selling/merging the owner removes its tokens, moving it on the board (also when a summon dragged onto
|
||||
// withdrawing/selling/merging the owner removes its tokens (an elite that takes a merged copy's tile gets a fresh
|
||||
// stack of its own, like any deployment), moving it on the board (also when a summon dragged onto
|
||||
// it swaps it away) sends its placed summons back onto their stack ("移动干员时,其所属召唤物全部退场并重置至手牌区");
|
||||
// a summon stack removed from temp at a prep deadline comes back at the next round start (startRound tops every
|
||||
// board owner's summons up to the deploy limit, "干员所属召唤物会于下一回合返还"). In battle a skill's summon takes its
|
||||
@@ -51,9 +54,9 @@
|
||||
// it during INFO_CHECK only. battleInput() resolves every chess unit to `skillIndex` + `moduleId` (resolveLoadout:
|
||||
// normal chess → moduleId null, elite → uniEquipId | 'none'); m.private exposes `loadout`.
|
||||
|
||||
import { ERR, GEO, PHASE } from '../../shared/constants.js';
|
||||
import { ERR, GEO, PHASE, layerGainRoom } from '../../shared/constants.js';
|
||||
import { checkLoadout, resolveLoadout } from '../../shared/protocol.js';
|
||||
import { FIELD, tileKey, parseKey, inField, canPlace, positionClass, boardOrder, freeSlot, pieceDir, parseDir } from './board.js';
|
||||
import { FIELD, tileKey, parseKey, inField, canPlace, positionClass, boardOrder, freeSlot, pieceDir, parseDir, mergeTile } from './board.js';
|
||||
import { offsetTile } from '../sim/dir.js';
|
||||
import { computeBonds, bondList, bondSnapshot, activatedLayers } from './bondsMeta.js';
|
||||
import { itemKey } from './gamedata.js';
|
||||
@@ -461,15 +464,28 @@ export class PlayerState {
|
||||
|
||||
/**
|
||||
* Merge `need` normal copies of `baseId` (the incoming, not yet stowed piece first, then temp, hand, board) into
|
||||
* the elite. Returns the elite piece (or null if the elite could not be stored).
|
||||
* the elite — PRTS 卫戍协议/帮助 §干员的获得与精锐化: "发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员,
|
||||
* 则发送至作战区对应位置)" (the user's playtest #6 follow-up confirms it). The tile (`mergeTile`): when a consumed copy
|
||||
* stood on the board the elite takes its tile and facing — of several, the one that deploys first (board reading
|
||||
* order: top → bottom, then left → right) [ASSUMED]; `fromKey` / `fromDir` = the tile the incoming piece stood on (a
|
||||
* transformation of a deployed operator) counts as such a copy. It replaces a deployed copy, so the deploy count never
|
||||
* grows. Otherwise the elite goes to the hand, overflow temp — outside PREP too (a SETTLE merge's elite waits in temp
|
||||
* through the next prep, tempDue). The copies' equipment returns to the hand ("干员晋级后已配发装备会回收至整备区";
|
||||
* overflow temp; with both full it stays on the elite, up to its equipPerChess (2) slots — any further item is
|
||||
* destroyed with a log warning, as before the official rule); their summons are removed, and an elite on the board
|
||||
* gets its own summon stack (grantTokensFor: its loadout, like any deployment). Returns the elite piece (or null if
|
||||
* the elite could not be stored).
|
||||
* @param {string} baseId
|
||||
* @param {any} incoming the acquired, not yet stowed copy (null: only owned copies)
|
||||
* @param {{ fromKey?: string|null, fromDir?: string }} [opts]
|
||||
*/
|
||||
_mergeChess(baseId, incoming) {
|
||||
_mergeChess(baseId, incoming, { fromKey = null, fromDir = undefined } = {}) {
|
||||
const need = this.gd.mergeCount(baseId);
|
||||
const goldenId = this.gd.goldenIdOf(baseId);
|
||||
if (!(need > 1) || !goldenId) return null;
|
||||
const locs = this._chessLocations().filter((l) => !this.gd.isGolden(l.piece.id) && this.gd.baseIdOf(l.piece.id) === baseId);
|
||||
const consumed = [];
|
||||
if (incoming) consumed.push({ piece: incoming, area: 'new' });
|
||||
if (incoming) consumed.push({ piece: incoming, area: 'new', key: fromKey || undefined, dir: fromDir });
|
||||
for (const l of locs) { if (consumed.length >= need) break; consumed.push(l); }
|
||||
if (consumed.length < need) return null;
|
||||
let copies = 0;
|
||||
@@ -482,27 +498,21 @@ export class PlayerState {
|
||||
l.piece.items = [];
|
||||
}
|
||||
const elite = this.newPiece('chess', goldenId, { poolCopies: copies });
|
||||
const boardLoc = consumed.find((l) => l.area === 'board' && !this.board.has(l.key));
|
||||
const toBoard = () => {
|
||||
// same operator as a consumed copy, so its freed tile is legal; it keeps that copy's facing
|
||||
elite.dir = pieceDir(boardLoc.piece);
|
||||
this.board.set(boardLoc.key, elite);
|
||||
this.grantTokensFor(elite);
|
||||
return 'board';
|
||||
};
|
||||
// the elite goes to the hand, overflow temp — outside PREP too (a merge completed by SETTLE / battle-result effects):
|
||||
// a temp piece that arrived after the prep waits there through the next prep (tempDue)
|
||||
let where = this.stow(elite, { allowTemp: true });
|
||||
if (!where && boardLoc) {
|
||||
// hand and temp both full ⇒ every consumed copy stood on the board: the elite takes the first freed tile
|
||||
// instead of being lost
|
||||
where = toBoard();
|
||||
}
|
||||
const deployed = consumed.filter((l) => l.key && !this.board.has(l.key)).map((l) => ({ key: l.key, dir: l.area === 'new' ? l.dir : pieceDir(l.piece) }));
|
||||
const toTile = (t) => { elite.dir = parseDir(t.dir) || 'RIGHT'; this.board.set(t.key, elite); return 'board'; };
|
||||
const tile = mergeTile(deployed, (r, c) => this._legal(elite, r, c));
|
||||
let where = tile ? toTile(tile) : this.stow(elite, { allowTemp: true });
|
||||
// hand and temp full and no deployed tile legal for it (a terrain change not re-checked yet): it stays on the first
|
||||
// deployed copy's tile rather than being lost, like a piece _evictIllegal finds no room for
|
||||
if (!where && deployed.length) where = toTile(mergeTile(deployed));
|
||||
for (const it of items) {
|
||||
if (this.stow(it, { allowTemp: true })) continue;
|
||||
if (where && elite.items.length < this.gd.equipPerChess) { elite.items.push(it); continue; }
|
||||
this.m.log.warn?.(`[match ${this.m.roomCode}] ${this.playerId}: returned item ${it.id} destroyed (no space)`);
|
||||
}
|
||||
// deployed like any operator placed by hand: its manually deployable summons join the hand (after the returned
|
||||
// equipment, which would be lost in temp — a summon stack removed there comes back at the next round start)
|
||||
if (where === 'board') this.grantTokensFor(elite);
|
||||
if (!where) {
|
||||
this.m.pool.give(baseId, copies);
|
||||
this.m.toast(this, 'warn', '整备区已满,晋升的精锐干员无法放入');
|
||||
@@ -514,7 +524,7 @@ export class PlayerState {
|
||||
this.pushRewardOffer('merge');
|
||||
const rec = this.gd.chess(goldenId);
|
||||
this.m.tickerFor('GOLDEN_CHAR', [this.name, rec ? rec.name : goldenId], { playerId: this.playerId });
|
||||
this.m.dispatch(this, 'onMerge', { kind: 'chess', piece: elite, baseId, consumed: consumed.map((l) => l.piece.uid) });
|
||||
this.m.dispatch(this, 'onMerge', { kind: 'chess', piece: elite, baseId, consumed: consumed.map((l) => l.piece.uid), area: where });
|
||||
return elite;
|
||||
}
|
||||
|
||||
@@ -533,7 +543,8 @@ export class PlayerState {
|
||||
|
||||
/**
|
||||
* Replace a chess piece by another chess (突变细胞 and similar). The new piece keeps the tile when legal (else goes
|
||||
* to the hand/temp) and keeps the equipment; pool copies are swapped; completes a merge when possible.
|
||||
* to the hand/temp) and keeps the equipment; pool copies are swapped; completes a merge when possible — a deployed
|
||||
* piece's tile then counts as a consumed copy's for the elite (_mergeChess fromKey, when legal for it).
|
||||
*/
|
||||
transformChess(piece, newId) {
|
||||
const loc = this.find(piece.uid);
|
||||
@@ -549,7 +560,7 @@ export class PlayerState {
|
||||
let np = this.newPiece('chess', newId, { poolCopies: taken });
|
||||
np.items = items;
|
||||
if (!rec.isGolden && this.completesChessMerge(newId)) {
|
||||
np = this._mergeChess(base, np);
|
||||
np = this._mergeChess(base, np, loc.area === 'board' ? { fromKey: loc.key, fromDir: pieceDir(piece) } : undefined);
|
||||
if (!np) { this.recompute(); return null; }
|
||||
} else if (loc.area === 'board') {
|
||||
const [r, c] = parseKey(loc.key);
|
||||
@@ -724,13 +735,16 @@ export class PlayerState {
|
||||
this.m.dispatch(this, 'onSpend', { amount, reason, total: this.stats.gold });
|
||||
}
|
||||
|
||||
/** Bond layer gain (prep-side). `requireActive` = "使已激活的【X】层数+N". Returns layers added. */
|
||||
/**
|
||||
* Bond layer gain (prep-side). `requireActive` = "使已激活的【X】层数+N". Returns layers added: at most the room left
|
||||
* under BOND_LAYER_CAP (999, shared/constants.js) — a gain at the cap adds 0 and dispatches nothing.
|
||||
*/
|
||||
addLayers(bondId, n, { requireActive = false, reason = '' } = {}) {
|
||||
if (!this.gd.bond(bondId) || !Number.isFinite(n) || n <= 0) return 0;
|
||||
if (requireActive && !(this.bonds[bondId] && this.bonds[bondId].active)) return 0;
|
||||
const add = Math.floor(n);
|
||||
if (add <= 0) return 0;
|
||||
const before = this.layers[bondId] || 0;
|
||||
const add = layerGainRoom(before, Math.floor(n));
|
||||
if (add <= 0) return 0;
|
||||
this.layers[bondId] = before + add;
|
||||
this.recompute();
|
||||
this.m.dispatch(this, 'onLayers', { bondId, from: before, to: before + add, reason });
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
// freeze toggle released
|
||||
// prep handlers buy / sell / refresh / levelUp pay exactly price / +sell price / refresh price (free first) / level
|
||||
// price, the level rises by one and its price resets to the next base; Ready only with an empty temp
|
||||
// merges a merge consuming a deployed copy puts the elite on that copy's tile (of several, the first in deploy
|
||||
// order legal for it — board.js mergeTile; a transformed piece's own tile counts) with its facing, else
|
||||
// into the hand / temp; the deploy count never grows (PRTS 卫戍协议/帮助, user playtest #6 follow-up)
|
||||
// combat start nothing overdue in temp, everyone ready, funds lost (carry bands excepted), unfrozen shop cleared,
|
||||
// one field per alive player
|
||||
// drafts every seat holds an allowed band with LP = totalHp; 机变: one card per alive player, card ↔ picker
|
||||
@@ -38,6 +41,7 @@
|
||||
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { collectViolations } from './invariants.js';
|
||||
import { mergeTile, pieceDir, canPlace, positionClass } from './board.js';
|
||||
import { pairPlayers, bossPoolHp, hiddenEligible } from './finalAssault.js';
|
||||
import { helperOrder } from './unite.js';
|
||||
import { BAND_TURN_SECONDS } from './Match.js';
|
||||
@@ -221,6 +225,36 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
|
||||
});
|
||||
return res;
|
||||
});
|
||||
// merges (PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置", user playtest #6 follow-up): with a
|
||||
// deployed copy among the consumed ones the elite stands on the first such tile in deploy order that is legal for
|
||||
// it (the incoming piece's own tile when a transformation merged it in place), with that copy's facing; else in the
|
||||
// hand / temp. A merge never grows the deploy count. Every owned normal copy is consumed (merges are immediate).
|
||||
wrap(ps, '_mergeChess', function (orig, baseId, incoming, opts) {
|
||||
const tiles = new Map(); // tile key → facing of the copy standing there
|
||||
for (const [k, p] of ps.board) if (p.kind === 'chess' && !gd.isGolden(p.id) && gd.baseIdOf(p.id) === baseId) tiles.set(k, pieceDir(p));
|
||||
if (opts && opts.fromKey) tiles.set(opts.fromKey, pieceDir({ dir: opts.fromDir }));
|
||||
// a transformation (transformChess) detached its deployed carrier before the merge: it still counts as deployed
|
||||
const deployed0 = ps.deployCount + (opts && opts.fromKey ? 1 : 0);
|
||||
const elite = orig(baseId, incoming, opts);
|
||||
if (elite) check('merge', () => {
|
||||
const id = ps.playerId;
|
||||
const loc = ps.find(elite.uid);
|
||||
if (!loc) { fail(`${id}: the elite of ${baseId} is not owned after its merge`); return; }
|
||||
if (ps.deployCount > deployed0) fail(`${id}: a merge of ${baseId} grew the deploy count ${deployed0} → ${ps.deployCount}`);
|
||||
// a pure read of the deploy field (Match.deployMapFor, as invariants.js): the audit must not refresh the cache
|
||||
const dmap = typeof m.deployMapFor === 'function' ? m.deployMapFor(ps) : ps.deployMap();
|
||||
const pos = positionClass(gd.chess(elite.id));
|
||||
const want = mergeTile([...tiles.keys()].map((key) => ({ key })), (r, c) => canPlace(dmap, pos, r, c));
|
||||
if (want) {
|
||||
if (loc.area !== 'board' || loc.key !== want.key) fail(`${id}: the elite of ${baseId} went to ${loc.area} ${loc.key || ''}, expected the deployed copy's tile ${want.key}`);
|
||||
else if (pieceDir(elite) !== tiles.get(want.key)) fail(`${id}: the elite of ${baseId} faces ${pieceDir(elite)}, its copy faced ${tiles.get(want.key)}`);
|
||||
} else if (loc.area === 'board' && (!tiles.has(loc.key) || ps.hand.some((x) => x == null) || ps.temp.some((x) => x == null))) {
|
||||
// only the no-room fallback (hand and temp full, no deployed tile legal for it) leaves it on a copy's tile
|
||||
fail(`${id}: the elite of ${baseId} took tile ${loc.key} although no consumed copy stood on a legal tile`);
|
||||
}
|
||||
});
|
||||
return elite;
|
||||
});
|
||||
wrap(ps, 'endPrep', function (orig) {
|
||||
// a temp piece that got there by other means (not _putTemp) was in temp during this prep: due now
|
||||
for (const p of ps.temp) if (p && !dueOf(ps).has(p.uid)) dueOf(ps).set(p.uid, endedOf(ps));
|
||||
|
||||
@@ -151,6 +151,21 @@ export function boardOrder(board) {
|
||||
.sort((a, b) => b.r - a.r || a.c - b.c);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a merge's elite stands (PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置"): of the board tiles
|
||||
* the consumed copies stood on (`[{ key, dir }]`), the first in deployment order (top→bottom, left→right — the copy the
|
||||
* battle deploys first; the right-hand boss half is mirrored on screen but deploys in the same board order) that
|
||||
* `legal(r, c)` accepts for the elite, or null (⇒ the hand). [ASSUMED] the order: the official text names one position.
|
||||
* public/js/ui/gameLogic.js mergeTarget mirrors it for the client.
|
||||
* @param {Array<{ key: string, dir?: string }>} tiles
|
||||
* @param {(r: number, c: number) => boolean} [legal]
|
||||
* @returns {{ key: string, dir?: string, r: number, c: number } | null}
|
||||
*/
|
||||
export function mergeTile(tiles, legal = () => true) {
|
||||
const sorted = (tiles || []).map((t) => { const [r, c] = parseKey(t.key); return { ...t, r, c }; }).sort((a, b) => b.r - a.r || a.c - b.c);
|
||||
return sorted.find((t) => inField(t.r, t.c) && legal(t.r, t.c)) || null;
|
||||
}
|
||||
|
||||
/** Index of the free slot to fill (right→left, config.economy.handFillOrder), or -1. */
|
||||
export function freeSlot(arr) {
|
||||
for (let i = arr.length - 1; i >= 0; i--) if (arr[i] == null) return i;
|
||||
|
||||
+3
-1
@@ -12,7 +12,9 @@
|
||||
// (free levels always; the −1/round discount is waited for early), then keep buying / refreshing with what is
|
||||
// left: merge progress (pairs → elites) > bond thresholds (focus core bond, add-ons 2/3) > role needs
|
||||
// (blockers, anti-air when the wave flies, one or two healers) > tier; with a full board a buy must improve the
|
||||
// best lineup. Items only when a deployed operator can carry them.
|
||||
// best lineup. Items only when a deployed operator can carry them. A merge that consumes a deployed copy leaves
|
||||
// the elite on that copy's tile (PRTS 卫戍协议/帮助, PlayerState._mergeChess): nothing here assumes it in the hand —
|
||||
// steps 3–4 plan it like any owned unit (kept, moved or benched).
|
||||
// 3. lineup: the deployed set maximizes unit value + activated bond tiers (exact counting via computeBonds) +
|
||||
// composition (chooseLineup: greedy seed + swap hill-climbing).
|
||||
// 4. placement (planLayout): the round's routes are traced over the own board from the enemy preview (ground
|
||||
|
||||
+13
-5
@@ -30,6 +30,7 @@
|
||||
// offspring bound) — the live counter's clamp
|
||||
// syntheticResult(players, progress) stand-in when a boss field's client never reported
|
||||
import { TICK, SNAPSHOT_EVERY } from '../sim/constants.js';
|
||||
import { layerGainRoom } from '../../shared/constants.js';
|
||||
import { uniteLeft } from '../sim/spec.js';
|
||||
|
||||
export const MAX_TICKS_PER_INTERVAL = 8;
|
||||
@@ -633,7 +634,13 @@ export function specBounds(spec, gd = null) {
|
||||
if (gd) for (const t of summonsOf(gd, defId)) defIds.add(t);
|
||||
}
|
||||
}
|
||||
players.set(p.playerId, { chess, all, defIds, bonds: gd ? layerBondsOf(p, gd) : null });
|
||||
// the layers each bond starts the battle with (PlayerBattleInput.bonds): a gain never passes BOND_LAYER_CAP
|
||||
const startLayers = new Map();
|
||||
for (const [id, b] of Object.entries(p.bonds && typeof p.bonds === 'object' ? p.bonds : {})) {
|
||||
const v = Number(b && b.layers);
|
||||
if (Number.isFinite(v) && v > 0) startLayers.set(id, v);
|
||||
}
|
||||
players.set(p.playerId, { chess, all, defIds, bonds: gd ? layerBondsOf(p, gd) : null, startLayers });
|
||||
}
|
||||
const round = Number(spec && spec.round) || 0;
|
||||
return {
|
||||
@@ -679,9 +686,9 @@ const sameMods = (a, b) => {
|
||||
* `counted: false` only for enemies that never count (data notCountInTotal, countInTotal false, boss / part entries,
|
||||
* content spawns); 联防: leaks + never-spawned re-entries per (enemy, leaker) ≤ what that leaker sent in, and a split /
|
||||
* summon only on a leaker who sent in its parent, ≤ the parents' data offspring count (offspringPerParent); per-bond layer
|
||||
* gains ≤ 60 + 4·round, only on bonds the player's lineup / band / effects / items name, none when the spec disables
|
||||
* gains; coins ≤ the spawns' bounty coins; perfect consistent with the counted leaks; unit states only for the
|
||||
* player's own units, within range.
|
||||
* gains ≤ 60 + 4·round and ≤ the room left under BOND_LAYER_CAP (999) from the bond's starting layers, only on bonds the
|
||||
* player's lineup / band / effects / items name, none when the spec disables gains; coins ≤ the spawns' bounty coins;
|
||||
* perfect consistent with the counted leaks; unit states only for the player's own units, within range.
|
||||
* @returns {{ ok: true, result: object } | { ok: false, reason: string }}
|
||||
*/
|
||||
export function validateClientResult(spec, raw, { gd = null } = {}) {
|
||||
@@ -763,7 +770,8 @@ export function validateClientResult(spec, raw, { gd = null } = {}) {
|
||||
if (typeof p.perfect !== 'boolean' || p.perfect !== (countedLeaks === 0)) return bad('perfect');
|
||||
const layerGains = {};
|
||||
for (const [bondId, n] of Object.entries(p.layerGains || {})) {
|
||||
if (!finiteIn(n, 0, B.layerCap)) return bad('layer bound');
|
||||
// ≤ 60 + 4·round, and never past BOND_LAYER_CAP from the layers the bond started with (Battle.addLayers clamps)
|
||||
if (!finiteIn(n, 0, Math.min(B.layerCap, layerGainRoom(own.startLayers.get(bondId) || 0, Infinity)))) return bad('layer bound');
|
||||
if (n > 0 && spec.flags && spec.flags.layerGainsEnabled === false) return bad('layers disabled');
|
||||
if (gd && typeof gd.bond === 'function' && !gd.bond(bondId)) return bad('bond');
|
||||
if (n > 0 && own.bonds && !own.bonds.has(bondId)) return bad('layer bond');
|
||||
|
||||
@@ -8,10 +8,13 @@
|
||||
// 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 (research 08 §6 #7, DESIGN §14 corrections): co-op = bloodPoint[difficulty] as is — split
|
||||
// leaders share it, "敌方领袖的总生命值不变", no alive-player factor; solo = bloodPoint × config bossHpScale.solo (0.25,
|
||||
// flagged unknown); × the tuning bossHpMul when data/tuning.json still has one (docs/BALANCE.md); shared by every
|
||||
// boss field; bosses are never scaled by enemyScale.
|
||||
// * Shared boss HP pool (DESIGN §20.10, GameData.bossPoolShare): one pool shared by every boss field (official tip
|
||||
// "所有人将一起对敌方领袖造成伤害"); co-op = bloodPoint[difficulty] whatever the number of alive players (notice 5114's
|
||||
// "敌方领袖的总生命值不变" is about the mirrored copies of a pair field sharing it, not about that number); config
|
||||
// bossHpScale.aliveScaling true scales it × alive / 4 (巴哈姆特 12294 "聯機隊友(撤退/死掉)變少,最後boss血條也會變少" — one
|
||||
// community note, no proportion; off until the user confirms it); solo = bloodPoint × config bossHpScale.solo (0.25,
|
||||
// flagged unknown); × the tuning bossHpMul when data/tuning.json still has one (docs/BALANCE.md); bosses are never
|
||||
// scaled by enemyScale.
|
||||
// * 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
|
||||
@@ -51,22 +54,22 @@ export function pairPlayers(alive) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Shared boss HP for a boss id. */
|
||||
/** Shared boss HP for a boss id with `aliveCount` alive players (GameData.bossPoolShare; 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 scale = gd.mode.bossHpScale && typeof gd.mode.bossHpScale === 'object' ? gd.mode.bossHpScale : {};
|
||||
const cfg = gd.config.bossHpScale && typeof gd.config.bossHpScale === 'object' ? gd.config.bossHpScale : {};
|
||||
const tune = typeof gd.bossHpMul === 'function' ? gd.bossHpMul(bossId) : 1;
|
||||
if (gd.isSolo) {
|
||||
const s = Number.isFinite(scale.solo) ? scale.solo : Number.isFinite(cfg.solo) ? cfg.solo : 0.25;
|
||||
return Math.max(1, Math.round(base * s * tune));
|
||||
let share;
|
||||
if (typeof gd.bossPoolShare === 'function') share = gd.bossPoolShare(aliveCount);
|
||||
else {
|
||||
const scale = gd.mode.bossHpScale && typeof gd.mode.bossHpScale === 'object' ? gd.mode.bossHpScale : {};
|
||||
const cfg = gd.config.bossHpScale && typeof gd.config.bossHpScale === 'object' ? gd.config.bossHpScale : {};
|
||||
share = gd.isSolo ? (Number.isFinite(scale.solo) ? scale.solo : Number.isFinite(cfg.solo) ? cfg.solo : 0.25) : 1;
|
||||
}
|
||||
void aliveCount; // co-op: the same pool whatever the number of alive players
|
||||
return Math.max(1, Math.round(base * tune));
|
||||
return Math.max(1, Math.round(base * share * tune));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -97,7 +97,7 @@ export class GameData {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* (finalAssault.bossPoolHp); use `bossPoolHp` / `bossPoolShare` for the official pool.
|
||||
* @deprecated
|
||||
*/
|
||||
bossHpMul(bossId) { // eslint-disable-line no-unused-vars
|
||||
@@ -105,23 +105,42 @@ export class GameData {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Official shared leader HP pool (DESIGN §20.10): ONE pool for every boss field of the match (official tip "最终攻势中,
|
||||
* 所有人将一起对敌方领袖造成伤害"; the mirrored copies of a pair field share it — notice 5114 "两侧的敌方领袖共享生命值
|
||||
* (敌方领袖的总生命值不变)", which is about those copies, not about the number of players). Co-op = bloodPoint
|
||||
* [difficulty]; with config bossHpScale.aliveScaling (default false) × alive / aliveFull (4) — 巴哈姆特 12294 "聯機隊友
|
||||
* (撤退/死掉)變少,最後boss血條也會變少" is one community note without a proportion, kept off until confirmed (it would
|
||||
* shorten fights after eliminations, the opposite of the playtest report); `aliveCount` omitted ⇒ a full team. Solo = bloodPoint ×
|
||||
* bossHpScale.solo (0.25 = one player of four, [ASSUMED]). Leaders are never scaled by enemyScale ("领袖单位于服务器的
|
||||
* 生命值加成不受上述加成影响").
|
||||
* @param {string} bossId
|
||||
* @param {number} [aliveCount] alive players at the Final Assault / Hidden Core start (co-op)
|
||||
* @returns {number}
|
||||
*/
|
||||
bossPoolHp(bossId) {
|
||||
bossPoolHp(bossId, aliveCount) {
|
||||
const boss = this.boss(bossId);
|
||||
const diff = this.difficulty;
|
||||
let base = boss && boss.bloodPoint && Number.isFinite(boss.bloodPoint[diff]) ? boss.bloodPoint[diff] : null;
|
||||
if (base == null && boss && boss.bloodPoint) base = Object.values(boss.bloodPoint).find((v) => Number.isFinite(v)) ?? null;
|
||||
if (base == null) base = 500000;
|
||||
return Math.max(1, Math.round(base * this.bossPoolShare(aliveCount)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiplier of bloodPoint for the leader pool (see bossPoolHp): solo = bossHpScale.solo (0.25); co-op = coop (1) ×
|
||||
* min(alive, aliveFull) / aliveFull when bossHpScale.aliveScaling (mode entry first, then the global one).
|
||||
* @param {number} [aliveCount]
|
||||
*/
|
||||
bossPoolShare(aliveCount) {
|
||||
const ms = this.mode.bossHpScale && typeof this.mode.bossHpScale === 'object' ? this.mode.bossHpScale : {};
|
||||
const cs = this.config.bossHpScale && typeof this.config.bossHpScale === 'object' ? this.config.bossHpScale : {};
|
||||
const pick = (k, d) => (Number.isFinite(ms[k]) && ms[k] > 0 ? ms[k] : Number.isFinite(cs[k]) && cs[k] > 0 ? cs[k] : d);
|
||||
const mul = this.isSolo ? pick('solo', 0.25) : pick('coop', 1);
|
||||
return Math.max(1, Math.round(base * mul));
|
||||
if (this.isSolo) return pick('solo', 0.25);
|
||||
const scaling = typeof ms.aliveScaling === 'boolean' ? ms.aliveScaling : cs.aliveScaling === true;
|
||||
const full = Math.max(1, Math.floor(pick('aliveFull', 4)));
|
||||
const n = Number(aliveCount);
|
||||
const alive = scaling && Number.isFinite(n) && n >= 1 ? Math.min(full, Math.floor(n)) : full;
|
||||
return pick('coop', 1) * (alive / full);
|
||||
}
|
||||
|
||||
/** config.titles with the tuning overrides (stat / rule per title id) merged in. */
|
||||
|
||||
@@ -8,12 +8,14 @@
|
||||
// copy, an elite ≤ goldenCopies; merges are immediate (never `mergeCount` normal copies of one chess, never
|
||||
// two copies of a mergeable normal item); every token's owner chess is deployed
|
||||
// board tiles inside the own region and legal for the piece; no items on the board; chess count ≤ deploy cap
|
||||
// bonds ps.bonds equals a fresh computeBonds() (every mutation recomputed them)
|
||||
// (where a merge's elite goes — a consumed deployed copy's tile, else the hand — needs the state before the
|
||||
// merge: audit.js checks it per merge)
|
||||
// bonds ps.bonds equals a fresh computeBonds() (every mutation recomputed them); every bond's layers 0 … BOND_LAYER_CAP
|
||||
// shop slot count follows the rolled layout; ids known; banned chess never offered by the shop / rewards
|
||||
// elim. an eliminated player owns nothing (board, hand, temp, shop, offers, bounties, funds)
|
||||
// match phase known; teamLp / boss pool within range; combat fields match the alive players
|
||||
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { PHASE, BOND_LAYER_CAP } from '../../shared/constants.js';
|
||||
import { FIELD, canPlace, positionClass, parseKey } from './board.js';
|
||||
import { computeBonds } from './bondsMeta.js';
|
||||
|
||||
@@ -52,6 +54,10 @@ export function collectViolations(m, { limit = 25 } = {}) {
|
||||
if (!(ps.shop.level >= 1 && ps.shop.level <= gd.maxShopLevel)) fail(`${id}: shop level ${ps.shop.level}`);
|
||||
if (!(ps.shop.upgradePrice >= 0)) fail(`${id}: upgradePrice ${ps.shop.upgradePrice}`);
|
||||
if (!Number.isInteger(ps.shop.freeRefreshes) || ps.shop.freeRefreshes < 0) fail(`${id}: freeRefreshes ${ps.shop.freeRefreshes}`);
|
||||
// the official per-bond layer cap (shared/constants.js): no writer may pass it
|
||||
for (const [b, v] of Object.entries(ps.layers || {})) {
|
||||
if (!(Number.isFinite(v) && v >= 0 && (!(BOND_LAYER_CAP > 0) || v <= BOND_LAYER_CAP))) fail(`${id}: ${b} layers ${v}`);
|
||||
}
|
||||
|
||||
if (!ps.alive) {
|
||||
if (ps.board.size) fail(`${id}: eliminated but keeps ${ps.board.size} board pieces`);
|
||||
|
||||
+48
-13
@@ -22,12 +22,12 @@
|
||||
// battle continues. After MAX_INTERNAL_ERRORS the battle force-ends as a timeout.
|
||||
|
||||
import { TICK, ROWS, COLS, BLOCK_RADIUS_SQ, DP_DEFAULTS, DOWN_STATE, FORCED_EXIT, MAX_BATTLE_TIME, MAX_INTERNAL_ERRORS, COLD_FREEZE_DURATION, OBSTACLE_DEVICES, EVENT_BUFFER_CAP, BOSS_ROW_OFFSET, MAX_HOOK_DEPTH, MAX_ALIVE_ENEMIES, LEVITATE_HALF_WEIGHT, RESIST_DEFAULT, RESIST_PALSY_DECAY, PUSH_TILES, PUSH_TILES_EFFECT, PULL_WEAK_SHARE, PULL_CRAWL, PULL_ORIGIN, PULL_STOP_RADIUS, PUSH_DIRECTIONAL_MIN_DIST, AUTO_OP_COOLDOWN } from './constants.js';
|
||||
import { GEO } from '../../shared/constants.js';
|
||||
import { GEO, layerGainRoom } from '../../shared/constants.js';
|
||||
import { createRng } from './rng.js';
|
||||
import { Grid } from './grid.js';
|
||||
import { Unit } from './units.js';
|
||||
import { makeBuff, STATUS, RESIST_STATUSES } from './buffs.js';
|
||||
import { dealDamage as pipeDamage, heal as pipeHeal, applyHpLoss, makeDamageInfo, reduceElement, palsyBuff, elementView } from './damage.js';
|
||||
import { dealDamage as pipeDamage, heal as pipeHeal, applyHpLoss, makeDamageInfo, reduceElement, palsyBuff, elementView, leaderHitCancelled } from './damage.js';
|
||||
import { absoluteRangeKeys, canTargetEnemy } from './targeting.js';
|
||||
import { bodyKeys, bodyInKeys, bodyInRadius } from './body.js';
|
||||
import { normDir, mirrorDir, localOrder, localBefore } from './dir.js';
|
||||
@@ -1351,19 +1351,39 @@ export class Battle {
|
||||
buff.data = { ...buff.data, attract: { r, c, pts: null, i: 0, ver: -1 } };
|
||||
}
|
||||
|
||||
/** "同名效果取最高": keep the strongest value; a weaker one that outlasts it resumes afterwards (buff.data.tail). */
|
||||
/**
|
||||
* "同名效果取最高" for a content effect that is not a catalogue status (no immunities, 抵抗, `beforeStatus` /
|
||||
* `statusApplied` hooks or status icon): ONE instance of `key` per target whatever the number of sources — the
|
||||
* strongest `value` applies, a weaker application only extends past the stronger one's end (it then resumes), an
|
||||
* equal one refreshes to the longer duration. `mods(value)` builds the buff's mods. The engine's default for two
|
||||
* same-named buffs (PRTS 作战机制 "同名buff的默认叠加策略buff只能表现出一个"). Returns true when applied.
|
||||
* @param {object} target
|
||||
* @param {string} key
|
||||
* @param {{ duration: number, value: number, mods: (v: number) => object, source?: object|null }} opts
|
||||
*/
|
||||
applyStrongest(target, key, { duration, value, mods, source = null } = {}) {
|
||||
if (!target || !target.alive || !(Number(duration) > 0) || !Number.isFinite(value) || typeof mods !== 'function') return false;
|
||||
this._applyValuedStatus(target, key, { mods, valued: value, plain: true }, Number(duration), value, source);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* "同名效果取最高": keep the strongest value; a weaker one that outlasts it resumes afterwards (buff.data.tail). A
|
||||
* `plain` template (applyStrongest) is an ordinary invisible buff, not a status.
|
||||
*/
|
||||
_applyValuedStatus(target, key, tpl, duration, value, source) {
|
||||
const strength = (v) => Math.abs(Number.isFinite(v) ? v : tpl.valued);
|
||||
const make = (v, dur, tail) => ({
|
||||
...(tpl.buff || null), // extra buff fields of the status (抵抗: the 麻痹 decay tick)
|
||||
key, duration: dur, refresh: 'replace', mods: tpl.mods(v), flags: tpl.flags || null, status: key, visible: true, source,
|
||||
key, duration: dur, refresh: 'replace', mods: tpl.mods(v), flags: tpl.flags || null, status: tpl.plain ? null : key,
|
||||
visible: !tpl.plain, source,
|
||||
data: { value: v, tail },
|
||||
onExpire: ({ battle, unit, buff }) => {
|
||||
const t = buff.data.tail;
|
||||
if (t && t.until - battle.time > 1e-6 && unit.alive) battle.addBuff(unit, make(t.value, t.until - battle.time, null));
|
||||
},
|
||||
});
|
||||
const old = target.buffs.find((b) => b.key === key && b.status === key);
|
||||
const old = target.buffs.find((b) => b.key === key && (tpl.plain || b.status === key));
|
||||
if (!old) { this.addBuff(target, make(value, duration, null)); return; }
|
||||
const oldV = old.data && Number.isFinite(old.data.value) ? old.data.value : tpl.valued;
|
||||
const oldEnd = this.time + old.timeLeft, newEnd = this.time + duration;
|
||||
@@ -1400,12 +1420,17 @@ export class Battle {
|
||||
* DamageInfo this loss derives from (damage passed on to a leader, split, shared…): its tags are inherited and it is
|
||||
* kept as `dmg.origin`, so `damaged` handlers that skip their own tagged damage also skip what it turned into; a loss
|
||||
* derived from 无来源 damage (`from.sourceless`, element bursts) is 无来源 too (hooks see no source, `source` is credited).
|
||||
* `sourceless: true` makes the loss itself 无来源 ("受到等量的无来源生命流失": hooks see no source; `source` keeps the
|
||||
* credit — the stats and the per-player shared-pool tally).
|
||||
* On a leader in a boss / hidden battle a loss of ≥ BOSS_HIT_LIMIT is cancelled like a hit (damage.js leaderHitCancelled).
|
||||
*/
|
||||
loseHp(target, amount, { source = null, silent = false, tags = null, from = null } = {}) {
|
||||
loseHp(target, amount, { source = null, silent = false, tags = null, from = null, sourceless = false } = {}) {
|
||||
if (!target || !target.alive || !(amount > 0)) return 0;
|
||||
// 限伤 (shared/constants.js BOSS_HIT_LIMIT): a loss passed on to a leader (parts' 传递, 无人机) is one hit too
|
||||
if (leaderHitCancelled(this, target, amount)) return 0;
|
||||
const t = ['hpLoss'];
|
||||
for (const list of [from && from.tags, tags]) if (Array.isArray(list)) for (const x of list) if (!t.includes(x)) t.push(x);
|
||||
return applyHpLoss(this, source, target, amount, { type: 'true', tags: t, noSp: true, silent, origin: from ?? null, sourceless: !!(from && from.sourceless) });
|
||||
return applyHpLoss(this, source, target, amount, { type: 'true', tags: t, noSp: true, silent, origin: from ?? null, sourceless: !!sourceless || !!(from && from.sourceless) });
|
||||
}
|
||||
|
||||
reduceElement(target, amount, element = null) { return reduceElement(target, amount, element); }
|
||||
@@ -1966,19 +1991,29 @@ export class Battle {
|
||||
return ps.dp;
|
||||
}
|
||||
|
||||
/** Record an IN_BATTLE layer gain (no-op when gains are disabled). Returns the layers added. */
|
||||
/**
|
||||
* Record an IN_BATTLE layer gain (no-op when gains are disabled). Returns the layers added: at most the room left
|
||||
* under BOND_LAYER_CAP (999, shared/constants.js) on the live copy — the client's AddBondCount `min(L + n, 999)`; the
|
||||
* `layerGain` hook (魔王's +1 …) runs first, then the clamp; a bond already at the cap gains 0 (no hook, no event).
|
||||
* Without a live copy of the bond (a partial PlayerBattleInput) the battle's own gains count; the match's settle
|
||||
* clamps the persistent count the same way.
|
||||
*/
|
||||
addLayers(playerId, bondId, n, reason = '', opts = {}) {
|
||||
if (!this.flags.layerGainsEnabled || !(n > 0) || !Number.isFinite(n) || playerId == null) return 0;
|
||||
const pp = this._pp(playerId);
|
||||
if (!pp) return 0;
|
||||
const ps = this.getPlayer(playerId);
|
||||
const live = ps && ps.bonds[bondId] ? (ps.bonds[bondId].layers ?? 0) : (pp.layerGains[bondId] ?? 0);
|
||||
if (!(layerGainRoom(live, Infinity) > 0)) return 0;
|
||||
const source = opts.source ?? null;
|
||||
const ctx = { playerId, bondId, n, reason, source, tile: Array.isArray(opts.tile) ? opts.tile : this._sourceTile(source) };
|
||||
if (this._hooks.layerGain) { this.emit('layerGain', ctx); if (!(ctx.n > 0) || !Number.isFinite(ctx.n)) return 0; }
|
||||
pp.layerGains[bondId] = (pp.layerGains[bondId] ?? 0) + ctx.n;
|
||||
const ps = this.getPlayer(playerId);
|
||||
if (ps && ps.bonds[bondId]) ps.bonds[bondId].layers = (ps.bonds[bondId].layers ?? 0) + ctx.n;
|
||||
this._ev(['layer', playerId, bondId, ctx.n]);
|
||||
return ctx.n;
|
||||
const add = layerGainRoom(live, ctx.n);
|
||||
if (!(add > 0)) return 0;
|
||||
pp.layerGains[bondId] = (pp.layerGains[bondId] ?? 0) + add;
|
||||
if (ps && ps.bonds[bondId]) ps.bonds[bondId].layers = (ps.bonds[bondId].layers ?? 0) + add;
|
||||
this._ev(['layer', playerId, bondId, add]);
|
||||
return add;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -200,3 +200,17 @@ export const BOSS_ROW_OFFSET = -7;
|
||||
* #6 item 5). With the rule a pool always reads 0 or at least 1 HP: 0 on the HUD = the leader is down.
|
||||
*/
|
||||
export const BOSS_POOL_MIN_HP = 1;
|
||||
|
||||
/**
|
||||
* How the percentage attribute bonuses (ATK / DEF / max HP "+X%") of the 卫戍 systems combine: 盟约, 策略, 装备, the
|
||||
* 机变 cards and the per-layer 特质 (garrisons' 每叠加N层 …+X%). Official: PRTS 卫戍协议:盟约 下半/PRTS盟约记录 "盟约效果,
|
||||
* 策略效果,装备效果提供的属性加成均为直接乘算"; 直接乘算 is the modifier class whose values are SUMMED with every other
|
||||
* 直接乘算 — a skill's "攻击力+X%" included — before multiplying (PRTS 游戏数据基础 属性基本公式 "直接乘算结果 D_t = t₁ + t₂ +
|
||||
* … + tₙ", A = (A₀ + D_p)(1 + D_t); PRTS 作战机制 X₂ = X₁(1 + b₁% + b₂%)). 'add' = that rule: the engine's additive
|
||||
* atkPct / defPct / hpPct (content/support directMods). 'multiply' = the v2.5 reading (each source its own ×(1 + x)
|
||||
* atkMul / defMul / hpMul), which compounded: at 600 精准 layers a ranged operator with four more such bonuses dealt
|
||||
* ×74 instead of ×12 and Final Assault leaders fell in seconds (user report after playtest #6). "提升至X%/X倍" effects
|
||||
* (炎佑 ×1.5, 攻击海怪敌人时攻击力提升至150%) and the char_attribute_mul 特质 (+20 % / +25 % on the chess itself, a rune on
|
||||
* the base attributes) stay multipliers.
|
||||
*/
|
||||
export const DIRECT_BONUS_STACKING = 'add';
|
||||
@@ -4,8 +4,8 @@
|
||||
// (numbers from the blackboard, never hard-coded). Band buffs apply in every battle kind (normal / 联防 / boss /
|
||||
// hidden); IN_BATTLE layer gains are no-ops where the match disables them (support.gainLayers).
|
||||
//
|
||||
// act1autochess_band2_buff 阿米娅 众志合一 ≥ value_i active bonds (highest i) ⇒ every operator ATK × (1 + atk_i),
|
||||
// HP × (1 + max_hp_i) (直接乘算, bonds snapshot at combat start)
|
||||
// act1autochess_band2_buff 阿米娅 众志合一 ≥ value_i active bonds (highest i) ⇒ every operator ATK +atk_i,
|
||||
// HP +max_hp_i (直接乘算 — support directMods —, bonds snapshot at combat start)
|
||||
// act1autochess_band28_buff 埃芒加德 命结之秘 the first max_respawn_cnt operator knock-downs of the battle revive at
|
||||
// once at full HP (runs after every other death saver, items included)
|
||||
// act1autochess_band13_buff 克莱门莎 崇高牺牲 a <bond_id> operator knocked down ⇒ +its tier (等阶) <bond_id> layers
|
||||
@@ -18,13 +18,13 @@
|
||||
// act1autochess_band18_buff 休谟斯 回收利用 a ground (地面) operator's skill ends ⇒ a random operator on its 4
|
||||
// neighbouring tiles gains `sp` SP
|
||||
// act1autochess_band15_buff 卡莱莎 食腐之蝶 an operator knocked down ⇒ every other unit of the player on the field
|
||||
// ATK × (1 + atk × n), n ≤ max_stack_cnt (+20 % … +200 %, additive stacks),
|
||||
// ATK +atk × n, n ≤ max_stack_cnt (+20 % … +200 %, additive stacks),
|
||||
// until that unit is knocked down itself or the battle ends
|
||||
// act1autochess_band19_buff 陈 以己之长 the player's operators' physical / arts damage becomes 弱点伤害
|
||||
// act2autochess_band12_buff 夕 墨色真颜 at combat start operators of which ≥ 2 of the same name (same base chess,
|
||||
// elite or not) stand on the field get ATK × (1 + atk)
|
||||
// elite or not) stand on the field get ATK +atk
|
||||
// act2autochess_band3_buff 伊奥莱塔 统御号令 at combat start n = elite operators on the field ⇒ every elite operator
|
||||
// ATK × (1 + atk_per_cnt × n), HP × (1 + max_hp_per_cnt × n)
|
||||
// ATK +atk_per_cnt × n, HP +max_hp_per_cnt × n (all 直接乘算)
|
||||
// auto_chess_change_map Touch 外勤医疗 "所有玩家场地上出现一名<预备干员-医疗>": while any alive player of the match
|
||||
// holds band_amedic, every player's field gets the map character of the
|
||||
// variant whose common_condition holds for that player's elites on the field
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
import {
|
||||
num, buffsOf, bandRecord, isOp, onField, isElite, tierOf, unitBonds, activeBondIds, playerOps, passiveBuff, fxOn,
|
||||
matchBands, gainLayers, alliesAround, N4, baseChessId, isGroundOp,
|
||||
matchBands, gainLayers, alliesAround, N4, baseChessId, isGroundOp, directMods,
|
||||
} from '../support/index.js';
|
||||
import { weaknessRetype, addShieldLayer, PRIO_REVIVE } from '../items/battle.js';
|
||||
import { spawnMapChar } from '../tokens.js';
|
||||
@@ -58,9 +58,7 @@ const BY_KEY = {
|
||||
if (n >= num(p[`value_${i}`], Infinity)) { atk = num(p[`atk_${i}`]); hp = num(p[`max_hp_${i}`]); }
|
||||
}
|
||||
if (!(atk > 0 || hp > 0)) return;
|
||||
const mods = {};
|
||||
if (atk) mods.atkMul = 1 + atk;
|
||||
if (hp) mods.hpMul = 1 + hp;
|
||||
const mods = directMods({ atk, hp });
|
||||
for (const u of playerOps(battle, ps.playerId)) passiveBuff(battle, u, keyOf(bandId), mods);
|
||||
},
|
||||
|
||||
@@ -157,7 +155,7 @@ const BY_KEY = {
|
||||
if (a === d) continue;
|
||||
const cur = a.findBuff(key);
|
||||
const n = Math.min(cap, (cur && cur.data ? cur.data.n : 0) + 1);
|
||||
battle.addBuff(a, { key, mods: { atkMul: 1 + step * n }, refresh: 'replace', data: { n }, visible: true });
|
||||
battle.addBuff(a, { key, mods: directMods({ atk: step * n }), refresh: 'replace', data: { n }, visible: true });
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -184,7 +182,7 @@ const BY_KEY = {
|
||||
}
|
||||
for (const list of groups.values()) {
|
||||
if (list.length < 2) continue;
|
||||
for (const u of list) passiveBuff(battle, u, keyOf(bandId), { atkMul: 1 + atk });
|
||||
for (const u of list) passiveBuff(battle, u, keyOf(bandId), directMods({ atk }));
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -197,9 +195,7 @@ const BY_KEY = {
|
||||
const elites = deployedOps(battle, ps.playerId).filter(isElite);
|
||||
const n = elites.length;
|
||||
if (!n) return;
|
||||
const mods = {};
|
||||
if (a) mods.atkMul = 1 + a * n;
|
||||
if (h) mods.hpMul = 1 + h * n;
|
||||
const mods = directMods({ atk: a * n, hp: h * n });
|
||||
for (const u of elites) passiveBuff(battle, u, keyOf(bandId), mods);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,33 +1,39 @@
|
||||
// server/sim/content/bonds/addon/battle.js — battle side of the 15 add-on bonds (research 02 §3.9–§3.23).
|
||||
//
|
||||
// Every number comes from data/bonds.json (`env_gbuff_new` buff blackboards). Effects only apply to the owning
|
||||
// Every number comes from data/bonds.json (`env_gbuff_new` buff blackboards). ATK / DEF / max HP "+X%" are 直接乘算
|
||||
// (support directMods: additive with every other percentage, PRTS 盟约记录). Effects only apply to the owning
|
||||
// player's units (`unit.ownerId === playerId`): two players sharing a boss / 联防 field never buff each other.
|
||||
// Layer-dependent values read the LIVE layers (`battle.getPlayer(pid).bonds[id].layers`): an IN_BATTLE layer gain
|
||||
// (engine `layerGain` hook, which fires before the layers are written) schedules one deferred recompute per player.
|
||||
//
|
||||
// 精准 preciShip members ATK ×(1 + base_atk + atk_per_stack·L); tier 2 (3 distinct): members + RANGED operators,
|
||||
// 精准 preciShip members ATK +(base_atk + atk_per_stack·L); tier 2 (3 distinct): members + RANGED operators,
|
||||
// which also ignore power_def_penetrate DEF / power_magic_resist_penetrate RES (defIgnorePct / resIgnorePct)
|
||||
// 迅捷 swiftShip member skill end → p = min(1, base_prob + prob_per_stack·L): +normal_sp SP; L ≥ power_bond_stack_cnt:
|
||||
// every operator's skill end rolls p again for +power_sp (members roll both, research [ASSUMED])
|
||||
// 灵巧 skillfulShip aura: members + operators on their 4 (L ≥ 40: 8) adjacent tiles ASPD +(base + per·L), once per unit
|
||||
// 奥术 arcaneShip member arts damage → target arts taken ×(base + per·L) for weak_duration s (one refreshing instance
|
||||
// per player); tier 2: ×power_weak_scale when the target is below hp_ratio at application
|
||||
// 坚守 steadShip all operators max HP ×(1 + base + per·L); tier 2: 40 % of a non-member operator's damage is borne by
|
||||
// 奥术 arcaneShip member arts damage → target arts taken ×(base + per·L) for weak_duration s; tier 2: ×power_weak_scale
|
||||
// when the target is below hp_ratio at application. ONE instance per target whatever applies it — the
|
||||
// two players of a pair field compete for it, the strongest wins (battle.applyStrongest, 同名效果取最高:
|
||||
// PRTS 作战机制 "同名buff的默认叠加策略buff只能表现出一个"; 巴哈姆特 12316 first-hand: "共享型buff會跟對面搶
|
||||
// 如果對面層數比你高就不需要再特別激活直接吃他的奧術buff"). v2.5 kept one per player, so two players'
|
||||
// instances multiplied (×5.4 × ×5.6 on a leader at ~250 layers, DESIGN §20.10)
|
||||
// 坚守 steadShip all operators max HP +(base + per·L); tier 2: 40 % of a non-member operator's damage is borne by
|
||||
// the members on the field (split evenly, sourceless true damage — already mitigated), members’ thorns
|
||||
// (base + per·L arts, sourceless, ≤ 1 per cd_duration per member) + 脆弱 ×damage_scale for weak[limit] s
|
||||
// 助力 deputShip all operators DEF ×(1 + base + per·L), redeploy time ×(1 + respawn_time)
|
||||
// (base + per·L arts, sourceless but credited to the member hit, ≤ 1 per cd_duration per member)
|
||||
// + 脆弱 ×damage_scale for weak[limit] s
|
||||
// 助力 deputShip all operators DEF +(base + per·L), redeploy time ×(1 + respawn_time)
|
||||
// 突袭 raidShip member idle ≥ no_attack_duration s (or skill ready) with no enemy in range → "保留技力立即再部署"
|
||||
// next to the most advanced ground enemy: a real redeployment (retreat + free redeploy on the
|
||||
// landing tile, full HP, `deploy` fires — 部署时 traits such as 史尔特尔, 突袭手雷, 卡西米尔, 叙拉古)
|
||||
// with its SP / charges kept (engine redeploy tile + keepSp); ATK/HP ×(1 + base + per·L)
|
||||
// with its SP / charges kept (engine redeploy tile + keepSp); ATK/HP +(base + per·L)
|
||||
// until it leaves the field; later redeploys use its board tile again;
|
||||
// L ≥ power_bond_stack_cnt: every operator ASPD +power_attack_speed
|
||||
// 不屈 indomShip ground operator knocked out → p = min(1, base + per·L) immediate free redeploy; tier 2: every
|
||||
// operator on the field +sp SP
|
||||
// 协防 emptyShip all operators phys/arts taken ×(1 − damage_resistance); members dealt ×damage_scale_normal
|
||||
// (elite ×damage_scale_extra)
|
||||
// 独行 soloShip the member(s) ATK ×(1 + atk), HP ×(1 + max_hp), +sp SP on every deploy
|
||||
// 绝技 suntShip tier 1: elite operators ATK ×(1 + power_atk); tier 2: elites (and their summons) SP cost ×sp_ratio
|
||||
// 独行 soloShip the member(s) ATK +atk, HP +max_hp, +sp SP on every deploy
|
||||
// 绝技 suntShip tier 1: elite operators ATK +power_atk; tier 2: elites (and their summons) SP cost ×sp_ratio
|
||||
// 远见 / 奇迹 / 投资人 / 调和 have no battle effect (prep side, membership in support/index.js).
|
||||
//
|
||||
// Hooks registered only when a bond needs them. Priorities: `hit` −20 (坚守 redirect, after other modifiers had their
|
||||
@@ -37,7 +43,7 @@ import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
|
||||
import { localOrder, localBefore } from '../../../dir.js';
|
||||
import {
|
||||
num, bondRecord, buffParams, bondTier, bondLayers, isMember, isElite, isGroundOp, onField, playerOps, passiveBuff,
|
||||
fxOn, N4, N8, bodyInKeys,
|
||||
fxOn, N4, N8, bodyInKeys, directMods,
|
||||
} from '../../support/index.js';
|
||||
|
||||
export const ID = Object.freeze({
|
||||
@@ -56,6 +62,9 @@ const AURA_POLL = 0.25;
|
||||
const RAID_POLL = 0.25;
|
||||
const RAID_SEARCH = 2; // landing tiles within this Chebyshev distance of the target enemy
|
||||
|
||||
/** 奥术 vulnerability mods for a multiplier (battle.applyStrongest). */
|
||||
const arcaneMods = (v) => ({ artsTakenMul: v });
|
||||
|
||||
/** `env_gbuff_new` blackboard of a bond (numbers only). */
|
||||
export function bondBb(bondId) {
|
||||
return buffParams(bondRecord(bondId), 'env_gbuff_new') ?? {};
|
||||
@@ -90,7 +99,7 @@ function applyPassives(battle, st) {
|
||||
if (t[ID.preci]) {
|
||||
const bb = st.bb[ID.preci];
|
||||
const wide = t[ID.preci] >= 2;
|
||||
const mods = { atkMul: 1 + num(bb.base_atk) + num(bb.atk_per_stack) * L(battle, st, ID.preci) };
|
||||
const mods = directMods({ atk: num(bb.base_atk) + num(bb.atk_per_stack) * L(battle, st, ID.preci) });
|
||||
if (wide) { mods.defIgnorePct = num(bb.power_def_penetrate); mods.resIgnorePct = num(bb.power_magic_resist_penetrate); }
|
||||
for (const u of st.ops) {
|
||||
if (st.members[ID.preci].has(u) || (wide && u.def?.position === 'RANGED')) passiveBuff(battle, u, KEY.preci, mods);
|
||||
@@ -98,12 +107,12 @@ function applyPassives(battle, st) {
|
||||
}
|
||||
if (t[ID.stead]) {
|
||||
const bb = st.bb[ID.stead];
|
||||
const mods = { hpMul: 1 + num(bb.base_max_hp) + num(bb.max_hp_per_stack) * L(battle, st, ID.stead) };
|
||||
const mods = directMods({ hp: num(bb.base_max_hp) + num(bb.max_hp_per_stack) * L(battle, st, ID.stead) });
|
||||
for (const u of st.ops) passiveBuff(battle, u, KEY.stead, mods);
|
||||
}
|
||||
if (t[ID.deput]) {
|
||||
const bb = st.bb[ID.deput];
|
||||
const mods = { defMul: 1 + num(bb.base_def) + num(bb.def_per_stack) * L(battle, st, ID.deput), redeployMul: Math.max(0, 1 + num(bb.respawn_time)) };
|
||||
const mods = directMods({ def: num(bb.base_def) + num(bb.def_per_stack) * L(battle, st, ID.deput) }, { redeployMul: Math.max(0, 1 + num(bb.respawn_time)) });
|
||||
for (const u of st.ops) passiveBuff(battle, u, KEY.deput, mods);
|
||||
}
|
||||
if (t[ID.raid]) {
|
||||
@@ -124,8 +133,8 @@ function applyPassives(battle, st) {
|
||||
if (t[ID.arcane]) {
|
||||
const bb = st.bb[ID.arcane];
|
||||
const m = num(bb.base_damage_scale, 1) + num(bb.damage_scale_per_stack) * L(battle, st, ID.arcane);
|
||||
st.arcaneMods = { artsTakenMul: Math.max(0, m) };
|
||||
st.arcaneLowMods = { artsTakenMul: Math.max(0, m * num(bb.power_weak_scale, 1)) };
|
||||
st.arcaneMul = Math.max(0, m);
|
||||
st.arcaneLowMul = Math.max(0, m * num(bb.power_weak_scale, 1));
|
||||
}
|
||||
if (t[ID.skillful]) {
|
||||
const bb = st.bb[ID.skillful];
|
||||
@@ -137,10 +146,10 @@ function applyPassives(battle, st) {
|
||||
}
|
||||
|
||||
function raidMods(bb, lv) {
|
||||
const mods = {
|
||||
atkMul: 1 + num(bb.base_atk) + num(bb.atk_per_stack) * lv,
|
||||
hpMul: 1 + num(bb.base_max_hp) + num(bb.max_hp_per_stack) * lv,
|
||||
};
|
||||
const mods = directMods({
|
||||
atk: num(bb.base_atk) + num(bb.atk_per_stack) * lv,
|
||||
hp: num(bb.base_max_hp) + num(bb.max_hp_per_stack) * lv,
|
||||
});
|
||||
const aspd = num(bb.base_attack_speed) + num(bb.attack_speed_per_stack) * lv;
|
||||
if (aspd) mods.aspd = aspd;
|
||||
return mods;
|
||||
@@ -161,12 +170,12 @@ function applyStatic(battle, st) {
|
||||
}
|
||||
if (t[ID.solo]) {
|
||||
const bb = st.bb[ID.solo];
|
||||
const mods = { atkMul: 1 + num(bb.atk), hpMul: 1 + num(bb.max_hp) };
|
||||
const mods = directMods({ atk: num(bb.atk), hp: num(bb.max_hp) });
|
||||
for (const u of st.members[ID.solo]) passiveBuff(battle, u, KEY.solo, mods);
|
||||
}
|
||||
if (t[ID.sunt]) {
|
||||
const bb = st.bb[ID.sunt];
|
||||
const mods = { atkMul: 1 + num(bb.power_atk) };
|
||||
const mods = directMods({ atk: num(bb.power_atk) });
|
||||
for (const u of st.ops) {
|
||||
if (!isElite(u)) continue;
|
||||
passiveBuff(battle, u, KEY.sunt, mods);
|
||||
@@ -438,9 +447,12 @@ export function install(battle) {
|
||||
t.mem[STEAD_CD] = battle.time;
|
||||
const src = c.source;
|
||||
if (!src || src.side !== 'enemy' || !src.alive) return;
|
||||
const d = battle.makeDamage({ amount: num(bb.base_damage_value) + num(bb.damage_value_per_stack) * L(battle, st, ID.stead), type: 'arts', canDodge: false });
|
||||
// "伤害来源受到(850+10×L)点法术伤害": 无来源 (no attacker multipliers, hooks see no source) but credited to the
|
||||
// member hit — its player's stats and shared-pool tally count it, like an element burst (v2.5: no credit at all,
|
||||
// up to 19 % of a co-op leader's pool and 88 % of a solo one went unattributed, DESIGN §20.10)
|
||||
const d = battle.makeDamage({ amount: num(bb.base_damage_value) + num(bb.damage_value_per_stack) * L(battle, st, ID.stead), type: 'arts', canDodge: false, sourceless: true });
|
||||
d.steadThorn = true;
|
||||
battle.dealDamage(null, src, d);
|
||||
battle.dealDamage(t, src, d);
|
||||
if (src.alive) battle.applyStatus(src, 'fragile', { duration: num(bb['weak[limit]'], 5), value: num(bb.damage_scale, 1) - 1, source: t });
|
||||
return;
|
||||
}
|
||||
@@ -451,17 +463,8 @@ export function install(battle) {
|
||||
if (!st || !st.tiers[ID.arcane] || !st.members[ID.arcane].has(src)) return;
|
||||
const bb = st.bb[ID.arcane];
|
||||
const low = st.tiers[ID.arcane] >= 2 && t.hpRatio < num(bb.hp_ratio);
|
||||
const mods = low ? st.arcaneLowMods : st.arcaneMods;
|
||||
const key = st.arcaneKey ?? (st.arcaneKey = `${KEY.arcane}:${st.pid}`);
|
||||
const dur = num(bb.weak_duration, 3);
|
||||
const b = t.findBuff(key);
|
||||
if (b) {
|
||||
b.timeLeft = dur;
|
||||
b.duration = dur;
|
||||
if (b.mods !== mods) { b.mods = mods; t.markDirty(); }
|
||||
} else {
|
||||
battle.addBuff(t, { key, duration: dur, mods });
|
||||
}
|
||||
// one 奥术 instance per target, the strongest (both players of a pair field included): see the header
|
||||
battle.applyStrongest(t, KEY.arcane, { duration: num(bb.weak_duration, 3), value: low ? st.arcaneLowMul : st.arcaneMul, mods: arcaneMods, source: src });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,21 @@
|
||||
// server/sim/content/bonds/core.js — the 8 core bonds (核心盟约, research 02 §3.1–3.8), battle side + prep side.
|
||||
//
|
||||
// 炎 yanShip members ATK ×(1 + base_atk + atk_per_stack·L); 6: one “炎佑” (its template ATK / max HP + 30 %
|
||||
// 炎 yanShip members ATK +(base_atk + atk_per_stack·L); 6: one “炎佑” (its template ATK / max HP + 30 %
|
||||
// of the 炎 ATK / max HP sums at battle start — PRTS "增加…(最终加算)"); 9: two 炎佑, ATK ×atk,
|
||||
// damage taken ×(1 − damage_resistance)
|
||||
// (every bond "+X%" ATK / max HP here is 直接乘算 — S.directMods, additive with the other percentages, PRTS 盟约记录)
|
||||
// 萨尔贡 sargonShip member skill start → every member on the field gets an independent stack (ASPD +base_attack_speed
|
||||
// for base_time + time_per_stack·L s, ≤ max_buff_stack_cnt); 6: each stack also ATK +base_atk
|
||||
// (additive, one atkMul); 6 + band_narant: instead lends the caster's equipment (tier ≤
|
||||
// (additive, one 直接乘算 buff); 6 + band_narant: instead lends the caster's equipment (tier ≤
|
||||
// filter_item_level) to the 8 surrounding operators (items.js lendItemEffects)
|
||||
// 维多利亚 victoriaShip members carrying equipment deal ×(base_damage_scale + damage_scale_per_stack·L);
|
||||
// 6: ATK +atk_normal_equip per item (+atk_golden_equip more per golden item);
|
||||
// 6: ATK +atk_normal_equip per item (+atk_golden_equip more per golden item; 直接乘算);
|
||||
// prep: every `layer` layers → `count` item(s) from `pool` (bond_layer_added_reward_equip)
|
||||
// 谢拉格 kjeragShip members deal ×base_damage_scale, vs cold / frozen enemies ×(base_ex_damage_scale +
|
||||
// ex_damage_scale_per_stack·L) instead; 6: cold wind (devices.js kjeragColdWind)
|
||||
// 拉特兰 lateranoShip member ammo skills start with floor(ammo × (1 + base_ammo_percent + ammo_percent_per_stack·L));
|
||||
// 6: every ammo used by a member → all members ATK +atk_per_consume (≤ max_atk_for_consume)
|
||||
// 阿戈尔 egirShip members max HP ×(1 + base_max_hp + max_hp_per_stack·L); battle start devour (see devour());
|
||||
// 阿戈尔 egirShip members max HP +(base_max_hp + max_hp_per_stack·L) (直接乘算); battle start devour (see devour());
|
||||
// 5: the first max_free_respawn_cnt members knocked out for the first time redeploy at once (free)
|
||||
// 叙拉古 siracusaShip every member deployment: ASPD +(base + per·L) for (base_duration + per·L) s; 6: 隐匿 for the same
|
||||
// time, and while hidden / end_duration s after, attacks proc (PRD, nominal `prob`) base_damage +
|
||||
@@ -176,8 +177,8 @@ const ownTag = (dmg, tag) => !!dmg && Array.isArray(dmg.tags) && dmg.tags.includ
|
||||
function installYan(battle, pid, bb, members) {
|
||||
const L = () => S.bondLayers(battle, pid, 'yanShip');
|
||||
const apply = () => {
|
||||
const mul = 1 + num(bb.base_atk) + num(bb.atk_per_stack) * L();
|
||||
for (const u of members) S.passiveBuff(battle, u, 'bond:yan', { atkMul: mul });
|
||||
const mods = S.directMods({ atk: num(bb.base_atk) + num(bb.atk_per_stack) * L() });
|
||||
for (const u of members) S.passiveBuff(battle, u, 'bond:yan', mods);
|
||||
};
|
||||
apply();
|
||||
onLayers(battle, pid, 'yanShip', apply);
|
||||
@@ -220,7 +221,7 @@ function installSargon(battle, pid, bb, members) {
|
||||
if (!atkStacks) return;
|
||||
let n = 0;
|
||||
for (const b of u.buffs) if (b.key === 'bond:sargon') n++;
|
||||
if (n > 0 && u.alive) battle.addBuff(u, { key: 'bond:sargon:atk', mods: { atkMul: 1 + perAtk * n } });
|
||||
if (n > 0 && u.alive) battle.addBuff(u, { key: 'bond:sargon:atk', mods: S.directMods({ atk: perAtk * n }) });
|
||||
else battle.removeBuff(u, 'bond:sargon:atk');
|
||||
};
|
||||
battle.on('skillStart', ({ unit }) => {
|
||||
@@ -278,7 +279,7 @@ function installVictoria(battle, pid, bb, members) {
|
||||
if (!ids.length) continue;
|
||||
let add = 0;
|
||||
for (const id of ids) add += S.isGoldenId(id) ? golden : normal;
|
||||
if (add > 0) S.passiveBuff(battle, u, 'bond:victoria:atk', { atkMul: 1 + add });
|
||||
if (add > 0) S.passiveBuff(battle, u, 'bond:victoria:atk', S.directMods({ atk: add }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +321,7 @@ function installLaterano(battle, pid, bb, members) {
|
||||
if (!memberSet.has(unit) || st.bonus >= cap) return;
|
||||
st.used++;
|
||||
st.bonus = Math.min(cap, st.used * per);
|
||||
for (const m of members) S.passiveBuff(battle, m, 'bond:laterano:ammo', { atkMul: 1 + st.bonus });
|
||||
for (const m of members) S.passiveBuff(battle, m, 'bond:laterano:ammo', S.directMods({ atk: st.bonus }));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -391,8 +392,8 @@ function devour(battle, pid, bb, members) {
|
||||
|
||||
function installEgir(battle, pid, bb, members) {
|
||||
const apply = () => {
|
||||
const mul = 1 + num(bb.base_max_hp, 0) + num(bb.max_hp_per_stack, 0) * S.bondLayers(battle, pid, 'egirShip');
|
||||
for (const u of members) S.passiveBuff(battle, u, 'bond:egir', { hpMul: mul });
|
||||
const mods = S.directMods({ hp: num(bb.base_max_hp, 0) + num(bb.max_hp_per_stack, 0) * S.bondLayers(battle, pid, 'egirShip') });
|
||||
for (const u of members) S.passiveBuff(battle, u, 'bond:egir', mods);
|
||||
};
|
||||
apply();
|
||||
onLayers(battle, pid, 'egirShip', apply);
|
||||
@@ -464,7 +465,7 @@ function installKazimierz(battle, pid, bb, members) {
|
||||
const cap = num(bb.base_max_atk_when_born, 0) + num(bb.max_atk_when_born_per_stack, 0) * S.bondLayers(battle, pid, 'kazimierzShip');
|
||||
const bonus = Math.max(0, Math.min(cap, st.deploys * num(bb.atk_when_born, 0)));
|
||||
if (!(bonus > 0)) return;
|
||||
for (const u of members) S.passiveBuff(battle, u, 'bond:kazimierz', { atkMul: 1 + bonus });
|
||||
for (const u of members) S.passiveBuff(battle, u, 'bond:kazimierz', S.directMods({ atk: bonus }));
|
||||
};
|
||||
battle.on('deploy', ({ unit }) => {
|
||||
if (!S.isOp(unit) || unit.ownerId !== pid) return;
|
||||
|
||||
+150
-50
@@ -2,8 +2,8 @@
|
||||
//
|
||||
// install(battle) ensures the enemy dispatch hooks (enemies.js) and attaches a boss kit to every spawned leader / part
|
||||
// listed in BOSS_KITS. Damage dealt TO a leader routes to the shared boss pool through the engine (tag 'boss' +
|
||||
// sharedBoss); parts that "传递伤害" push a share of the damage they take into the pool with battle.loseHp(leader, …)
|
||||
// credited to the attacker. Numbers come from the (template-overridden) talent / skill blackboards; the rest are named
|
||||
// sharedBoss); parts that "传递伤害" push the damage they take into the pool with a 无来源 battle.loseHp(leader, …)
|
||||
// credited to the attacker (PART_TRANSFER = 1, "等量"). Numbers come from the (template-overridden) talent / skill blackboards; the rest are named
|
||||
// constants ([ASSUMED] or PRTS-sourced). Boss HP rules ("<20 %", "≤50 %") read the leader's HP ratio, which the engine
|
||||
// syncs from the shared pool, so both mirrored copies switch phase together.
|
||||
//
|
||||
@@ -17,18 +17,23 @@
|
||||
// (skipped if the match already spawned a second copy, or opts.bossMirror === false).
|
||||
//
|
||||
// Leaders (see each kit for details):
|
||||
// boss_1/8 假想敌:胄 random arts beam (range 8); 灭顶之灾 刺胄之弹 at the highest-ATK operator (8-hit flying shell,
|
||||
// 3×3 stun + phys DoT on arrival); <20 %: damage ×0.5 + 2 shells; 死亡集群 drones — a drone
|
||||
// killed by an operator costs the leader 2 % max HP. boss_8 adds 斩胄之剑 / 破胄之锤.
|
||||
// 斩胄之剑 / 破胄之锤 hover invulnerable next to 胄, AoE attacks; dive at the lowest-ATK operator (3×3 stun + DoT);
|
||||
// 15 hits during the dive shoot it down: grounded 20 s, damage ×1.3, PART_TRANSFER to 胄.
|
||||
// boss_1/8 假想敌:胄 random arts beam (range 8); 灭顶之灾 one 刺胄之弹 at the highest-ATK operator in range (8-hit
|
||||
// flying shell, 3×3 stun + phys DoT on arrival); <20 %: damage ×0.5 (no extra shell: PRTS
|
||||
// 能力修正); 死亡集群 drones — a drone that dies costs the leader 2 % of its max HP = the shared
|
||||
// pool's (DRONE_LINK_BASE, [ASSUMED] reading). boss_8 adds 斩胄之剑 / 破胄之锤.
|
||||
// 斩胄之剑 / 破胄之锤 初始模式: hover invulnerable next to 胄, AoE attacks (锤 150 % ATK). 出击模式: blink to the
|
||||
// level's blink route, fly at the lowest-ATK operator (3×3 stun + DoT); 15 hits shoot it
|
||||
// down (瘫痪: stun 10 s, ground unit, damage ×1.3). Either way a new 初始模式 copy with its HP
|
||||
// replaces it at home. Every damage it takes costs 胄 as much (PART_TRANSFER, 无来源, 等量) —
|
||||
// grounded per both texts, during the dive per the PRTS talent [ASSUMED].
|
||||
// boss_2/9 假想敌:铳 unblockable; highest-DEF target in range; erosion; ASPD ramp (+80 × 5) on the same target
|
||||
// (floor 20); 最终之罚 charge at the highest-DEF ground unit (disabled by the h07_02 override).
|
||||
// boss_9: damage ×0.2 while springs live, 盲信之誓 links (100 phys/s on the lines), 末日布道 dash.
|
||||
// 碎铳之簧 a/b/c arts barrier (absorbs arts after RES; phys ×0.1, counter phys + erosion) / element shield
|
||||
// (phys+arts ×0.1, broken by its own element burst; every (spCost+1)-th attack a bouncing erosion
|
||||
// shot) / 5-hit shield (every (spCost+1)-th attack a ten-hit combo); unblockable while shielded;
|
||||
// shield back 25 s after breaking; PART_TRANSFER of damage to 铳 and, split, to the other springs.
|
||||
// shield back 25 s after breaking; every damage taken costs 铳 as much (PART_TRANSFER, 无来源)
|
||||
// and, split, the other springs.
|
||||
// boss_3/10 假想敌:管 summons 余音 (dark) every 40 s (20 s < 50 %); normal attacks strike dark 余音 (their pulse hurts
|
||||
// operators); 裂管之奏 3 strikes on every dark 余音 (AoE arts + apoptosis). boss_10 + 假想敌:弦
|
||||
// (invulnerable, gold 余音, 断弦之奏 AoE + flips them dark; leaves with 管).
|
||||
@@ -63,8 +68,42 @@ import {
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// constants
|
||||
|
||||
/** Share of the damage a grounded 剑/锤 or a 碎铳之簧 passes to its leader ("以一定比例") [ASSUMED]. */
|
||||
export const PART_TRANSFER = 0.5;
|
||||
/**
|
||||
* Share of the damage a 剑/锤 or a 碎铳之簧 takes that its leader loses: 1, 等量 — PRTS talents (“斩胄之剑” / “破胄之锤”
|
||||
* "受到伤害时令假想敌:胄受到等量的无来源生命流失"; 碎铳之簧 (all three) "…令全场范围内仇恨值最高的1名假想敌:铳受到等量的无来源
|
||||
* 生命流失"); the enemy data carries no ratio blackboard (the handbook's "以一定比例" is that 1). The loss is 无来源 (hooks see
|
||||
* no source; the attacker keeps the credit — stats, the per-player pool tally) and follows EVERY damage taken: a
|
||||
* 剑/锤 only takes damage outside its invulnerable 初始模式 (its 出击模式 dive and 【瘫痪】). The two official texts
|
||||
* disagree on the dive: the PRTS talent says "受到伤害时" (any damage), the in-game handbook (data/enemies.json abilities,
|
||||
* PRTS 能力) "被击落时受到伤害以一定比例传递" (only once shot down) — the dive hits passing on is [ASSUMED] (the talent
|
||||
* text; ≤ 15 hits per dive). A loss passed on to a leader is one hit for 限伤 (sim/damage.js leaderHitCancelled, via
|
||||
* Battle.loseHp). v2.5: 0.5 [ASSUMED], and a 剑/锤 passed damage on only while grounded (DESIGN §20.10, §20.13).
|
||||
*/
|
||||
export const PART_TRANSFER = 1;
|
||||
/** The 斩胄之剑 / 破胄之锤 share — the same PRTS 等量 as PART_TRANSFER (kept as a name for the blade kit and its tests). */
|
||||
export const BLADE_TRANSFER = PART_TRANSFER;
|
||||
/** 破胄之锤 normal attack: 150 % ATK (PRTS 天赋 "普通攻击对攻击范围内的所有我方单位造成攻击力150%的物理普通伤害"; 斩胄之剑 100 %). */
|
||||
const BLADE_ATK_SCALE = Object.freeze({ enemy_9015_acstmb: 1.5 });
|
||||
/**
|
||||
* Level branches of each part (act1autochess_h08_01 / _s): 斩胄之剑 spawns on the start of `left_hand_origin` (3,12),
|
||||
* 破胄之锤 on `right_hand_origin` (3,8). `<hand>_blink` = the preset route 掷剑 blinks to (its start; PRTS "将自身路径改为
|
||||
* 关卡预设路径并闪现至路径起点"), `<hand>_origin` = the route the new 初始模式 copy is summoned on ("以关卡预设路径召唤").
|
||||
*/
|
||||
const BLADE_HAND = Object.freeze({ enemy_9014_acstma: 'left_hand', enemy_9015_acstmb: 'right_hand' });
|
||||
/**
|
||||
* 死亡集群: a drone that dies (PRTS 能力修正 "该无人机单位死亡时", whoever kills it) costs 胄 `hp_ratio` (2 %) of "最大生命值"
|
||||
* (PRTS 假想敌:胄 "该妖怪死亡时令假想敌:胄受到最大生命值2%的真实伤害"). WHICH max HP is not documented [ASSUMED reading]:
|
||||
* 'pool' (default, = v2.5): the leader's max HP as the battle shows it — the shared pool (the engine syncs the unit's
|
||||
* HP to it). It is the same HP the "自身生命值低于20%" talent reads, and that one must read the pool (the
|
||||
* unit's data 600 000 is above the co-op 标准 pool 247 500 and above every solo pool, where a fixed 600 000
|
||||
* would never drop below 20 %). A drone = 2 % of the leader bar in every mode and difficulty (终极 72 000).
|
||||
* 'unit': the in-battle unit's data max HP (600 000 / hidden 1 200 000, 不死) — 12 000 / 24 000 per drone at every
|
||||
* difficulty: 0.33 % of the 终极 bar but 19 % of the solo 标准 bar (61 875), so drones decide solo fights.
|
||||
* Round 2 of the boss-HP review tried 'unit'; the review measured the solo regression, so the default is back to 'pool'.
|
||||
* 限伤 (shared/constants.js BOSS_HIT_LIMIT): the loss is one hit through Battle.loseHp — it lands up to the largest pool
|
||||
* today (boss_8 终极 7 200 000 → 144 000) and would be cancelled above a 14 999 950 pool (ceil(0.02 × pool) ≥ 300000).
|
||||
*/
|
||||
export const DRONE_LINK_BASE = 'pool';
|
||||
/** 卢西恩 / 不祥幻影 AoE radius (PRTS "半径2"). */
|
||||
const LUCIEN_AOE_RADIUS = 2;
|
||||
/** 余音 on-hit pulse radius (PRTS 1.6) and 合奏 radii per form (PRTS gold 0.8 / dark 1.6). */
|
||||
@@ -159,6 +198,13 @@ function toGoal(b, x, y, motion = 'WALK') {
|
||||
return { motion, start: [y, x], end: best, checkpoints: [] };
|
||||
}
|
||||
|
||||
/** The extra route of a template branch's first spawn (or null). */
|
||||
function branchRoute(tpl, name) {
|
||||
const br = tpl && tpl.branches && tpl.branches[name];
|
||||
const s = Array.isArray(br) && Array.isArray(br[0]) ? br[0][0] : null;
|
||||
return (s && tpl.extraRoutes && tpl.extraRoutes[s.routeIndex]) || null;
|
||||
}
|
||||
|
||||
/** Spawn one phase of a template branch. Returns the spawned enemies. */
|
||||
function branchSpawn(b, tpl, name, phaseIdx, { fallback = null, mods = null, at = null } = {}) {
|
||||
const br = tpl && tpl.branches && tpl.branches[name];
|
||||
@@ -295,10 +341,10 @@ function segDist(px, py, ax, ay, bx, by) {
|
||||
function kitHelm(ab, e, b, tpl) {
|
||||
const s1 = ab.sk['1'], s2 = ab.sk['2'];
|
||||
const lowRatio = T(ab, '1.hp_ratio') ?? 0, lowScale = T(ab, '1.damage_scale') ?? 1;
|
||||
const hidden = /_2$/.test(e.defId);
|
||||
const P = { low: false };
|
||||
const range = () => e.base.rangeRadius || 8;
|
||||
const shellTargets = (b2) => opsOnly(hidden ? targetsNear(b2, e, range()) : allTargets(b2, e));
|
||||
// 灭顶之灾 "触发索敌和普通攻击相同:选择攻击范围内攻击力最高的1名我方干员" (PRTS 技能, both copies)
|
||||
const shellTargets = (b2) => opsOnly(targetsNear(b2, e, range()));
|
||||
return [
|
||||
{
|
||||
spawn(b2, e2, a, ab2) { ab2.atkType = 'arts'; },
|
||||
@@ -307,22 +353,27 @@ function kitHelm(ab, e, b, tpl) {
|
||||
iv: 0.25,
|
||||
tick(b2, e2) {
|
||||
if (P.low || !(e2.hpRatio < lowRatio)) return;
|
||||
P.low = true; // <20 %: damage taken ×0.5, extra shell
|
||||
P.low = true; // <20 %: damage taken ×0.5 (no extra shell: see 灭顶之灾)
|
||||
b2.addBuff(e2, { key: 'boss:helmGuard', persist: true, visible: true, mods: { physTakenMul: lowScale, artsTakenMul: lowScale } }); // 物理与法术伤害降低
|
||||
b2.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: 'helmLow' });
|
||||
},
|
||||
},
|
||||
s1 && {
|
||||
cd: s1.cd, icd: s1.icd, cond: (b2) => shellTargets(b2).length > 0,
|
||||
fire(b2, e2) { // 【灭顶之灾】
|
||||
const n = P.low ? 2 : 1;
|
||||
const ts = shellTargets(b2).sort((p, q) => q.s.atk - p.s.atk || aggroCmp(p, q)).slice(0, n);
|
||||
for (const t of ts) fireShell(b2, e2, t);
|
||||
// 【灭顶之灾】 one shell, also below 20 %: the handbook's "额外发射<刺胄之弹>" is wrong in game (PRTS 能力修正 原因 6
|
||||
// "描述与游戏实际表现不符合": "【灭顶之灾】不会额外发射") and no blackboard holds a second shell (skill 1 has none)
|
||||
fire(b2, e2) {
|
||||
const t = shellTargets(b2).sort((p, q) => q.s.atk - p.s.atk || aggroCmp(p, q))[0];
|
||||
if (t) fireShell(b2, e2, t);
|
||||
},
|
||||
},
|
||||
s2 && {
|
||||
cd: s2.cd, icd: s2.icd,
|
||||
fire(b2, e2) { // 【死亡集群】: drones; one killed by an operator costs the leader hp_ratio × max HP
|
||||
// 【死亡集群】: a drone on the branch route; when it dies (PRTS 能力修正 "该无人机单位死亡时", whoever kills it) the leader
|
||||
// loses hp_ratio × max HP. Drone HP: `summon.hp_ratio` (隐秘核心 only) scales it [ASSUMED: the summon's max HP]; the
|
||||
// `max_hp` 0.5 both copies carry is not read [ASSUMED] — no source says what it does (PRTS 技能 lists the 2 % only,
|
||||
// the 妖怪 page has no summon rule, the level gives the 妖怪 no override; as an attribute key it would be +50 %).
|
||||
fire(b2, e2) {
|
||||
const hpMul = s2.bb['summon.hp_ratio'] > 0 ? s2.bb['summon.hp_ratio'] : null;
|
||||
const key = s2.bs.enemy_key ?? 'enemy_1005_yokai';
|
||||
const drones = branchSpawn(b2, tpl, s2.bs.branch_id ?? 'boss_summon_enemy', 0, {
|
||||
@@ -333,9 +384,10 @@ function kitHelm(ab, e, b, tpl) {
|
||||
const ratio = s2.bb.hp_ratio ?? 0;
|
||||
for (const d of drones) attach(b2, d, [{
|
||||
death(c, b3) {
|
||||
if (c.reason !== 'killed' || !c.killer || c.killer.side !== 'ally' || !(ratio > 0)) return;
|
||||
if (c.reason !== 'killed' || !(ratio > 0)) return; // a leak is no death
|
||||
const boss = e2.alive ? e2 : b3.aliveEnemies().find((o) => o.isBoss && o.defId === e2.defId);
|
||||
if (boss) { b3.loseHp(boss, boss.s.maxHp * ratio, { source: c.killer }); b3.fx('beam', { x: d.x, y: d.y, from: d.id, to: boss.id, kind: 'droneLink' }); }
|
||||
const by = c.killer && c.killer.side === 'ally' ? c.killer : null; // credited to the killing operator
|
||||
if (boss) { b3.loseHp(boss, droneLinkBase(boss) * ratio, { source: by }); b3.fx('beam', { x: d.x, y: d.y, from: d.id, to: boss.id, kind: 'droneLink' }); }
|
||||
},
|
||||
}]);
|
||||
},
|
||||
@@ -343,6 +395,16 @@ function kitHelm(ab, e, b, tpl) {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Max HP the 死亡集群 drone link reads (DRONE_LINK_BASE): 'pool' = the leader's shown max HP (the shared pool), 'unit' =
|
||||
* the unit's data max HP (template × spawn hpMul).
|
||||
*/
|
||||
export function droneLinkBase(boss, base = DRONE_LINK_BASE) {
|
||||
if (base !== 'unit' || !boss.def || !(boss.def.maxHp > 0)) return boss.s.maxHp;
|
||||
const m = boss.mods && Number(boss.mods.hpMul);
|
||||
return boss.def.maxHp * (Number.isFinite(m) && m > 0 ? m : 1);
|
||||
}
|
||||
|
||||
/** Launch a 刺胄之弹 from `boss` at `target`'s tile. */
|
||||
function fireShell(b, boss, target) {
|
||||
const tr = target.tileR, tc = target.tileC;
|
||||
@@ -368,54 +430,90 @@ function kitShell(ab, e) {
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* “斩胄之剑” / “破胄之锤” (PRTS 天赋 + 技能 掷剑 / 掷锤; numbers from skill 1's blackboard).
|
||||
* 初始模式 ('hover'): 无敌 + 自缚 [ASSUMED: also untargetable, so operators spend no attacks on it]; AoE physical attack
|
||||
* on every ally in its range (锤 150 % ATK, BLADE_ATK_SCALE).
|
||||
* 掷剑 (skill 1 cd / icd, an operator on the field) → 出击模式 ('dive'): no normal attack, vulnerable; blinks to the start
|
||||
* of its `<hand>_blink` route and flies at the tile of the lowest-ATK operator (无视无法选择).
|
||||
* Arriving (no 【瘫痪】): `stun` s stun + `dot_damage`/s for `dot_duration` s on the 3×3 around it, then it is replaced.
|
||||
* Once, after `max_hit_cnt` damage instances: 【瘫痪】 ('down') — `special_stun_duration` s, but it ends with its own
|
||||
* `stun` s stun ("该晕眩结束时也会结束【瘫痪】"): ground unit, damage taken ×`damage_scale`; when it ends, replaced.
|
||||
* Replaced: a new 初始模式 copy with its current HP is summoned on its `<hand>_origin` route (at home without a template),
|
||||
* then it is killed (强制击杀自身; a part: no kill count, no bounty). The copy is a new unit [ASSUMED: only its HP is
|
||||
* inherited] — its 掷剑 starts from the initial cooldown again, so the data's cooldown (80 s) never comes into play.
|
||||
* Every damage it takes is lost by 假想敌:胄 too (BLADE_TRANSFER = PART_TRANSFER 1, 无来源, credited to the attacker).
|
||||
*/
|
||||
function kitBlade(ab, e, b, tpl) {
|
||||
const s = ab.sk['1'];
|
||||
const bb = s ? s.bb : {};
|
||||
const hand = BLADE_HAND[e.defId];
|
||||
const P = { state: 'hover', home: { x: e.x, y: e.y }, hits: 0, target: null };
|
||||
const leader = (b2) => nearestOf(b2, e, (o) => o.isBoss && /enemy_9013_acstmk/.test(o.defId));
|
||||
const hover = (b2, on) => {
|
||||
if (on) b2.addBuff(e, { key: 'boss:hover', persist: true, visible: true, flags: { invulnerable: true, untargetable: true } });
|
||||
else b2.removeBuff(e, 'boss:hover');
|
||||
};
|
||||
const replace = (b2, e2) => { // 以关卡预设路径召唤一个继承自身生命值的初始模式的 copy,随后强制击杀自身
|
||||
P.state = 'gone';
|
||||
e2.motion = 'FLY';
|
||||
const origin = hand ? branchRoute(tpl, `${hand}_origin`) : null;
|
||||
const home = [P.home.y, P.home.x];
|
||||
const n = b2.spawnEnemy(e2.defId, {
|
||||
route: origin || { motion: 'FLY', start: home, end: home, steps: [{ t: 'wait', s: 99999 }] }, pos: origin ? undefined : home,
|
||||
tag: e2.tag, countInTotal: false, mods: e2.mods, ownerPlayerId: e2.ownerId,
|
||||
});
|
||||
if (n) { n.hp = Math.min(n.s.maxHp, e2.hp); b2.fx('blink', { x: n.x, y: n.y, id: n.id, fx: e2.x, fy: e2.y }); }
|
||||
b2.kill(e2, null);
|
||||
};
|
||||
const shotDown = (b2, e2) => { // 【瘫痪】
|
||||
P.state = 'down';
|
||||
e2.motion = 'WALK'; // 变为地面单位(不改变寻路方式)
|
||||
const stun = bb.stun ?? 0;
|
||||
b2.applyStatus(e2, 'stun', { duration: stun, source: null, force: true });
|
||||
b2.addBuff(e2, { key: 'boss:downed', duration: Math.min(bb.special_stun_duration ?? stun, stun), visible: true,
|
||||
mods: { dmgTakenMul: bb.damage_scale ?? 1 }, onExpire: () => { if (e2.alive && P.state === 'down') replace(b2, e2); } });
|
||||
b2.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: 'shotDown' });
|
||||
};
|
||||
return [
|
||||
{
|
||||
spawn(b2, e2) { hover(b2, true); b2.addBuff(e2, { key: 'boss:anchor', persist: true, flags: { noMove: true } }); },
|
||||
spawn(b2, e2) {
|
||||
hover(b2, true);
|
||||
b2.addBuff(e2, { key: 'boss:anchor', persist: true, flags: { noMove: true, unblockable: true } }); // 自缚 (moved by hand) · 不可阻挡
|
||||
if (BLADE_ATK_SCALE[e2.defId]) e2.profile.atkScale = BLADE_ATK_SCALE[e2.defId];
|
||||
},
|
||||
before(c, b2, e2) { const l = targetsNear(b2, e2, e2.base.rangeRadius || 1.6, { ranged: false }); if (l.length) c.targets = l; }, // 范围物理伤害
|
||||
taken(c, b2, e2) {
|
||||
if (P.state === 'dive' && ++P.hits >= (bb.max_hit_cnt ?? Infinity)) {
|
||||
// shot down: ground unit, damage ×damage_scale, stunned special_stun_duration, then flies home
|
||||
P.state = 'down';
|
||||
e2.motion = 'WALK';
|
||||
b2.addBuff(e2, { key: 'boss:downed', duration: bb.special_stun_duration ?? 0, visible: true, mods: { dmgTakenMul: bb.damage_scale ?? 1 },
|
||||
onExpire: () => { if (e2.alive) { e2.motion = 'FLY'; P.state = 'return'; } } });
|
||||
b2.applyStatus(e2, 'stun', { duration: bb.special_stun_duration ?? 0, source: null, force: true });
|
||||
b2.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: 'shotDown' });
|
||||
return;
|
||||
}
|
||||
if (P.state === 'down' && c.source && c.source.side === 'ally' && c.amount > 0) {
|
||||
if (c.amount > 0) { // 受到伤害时令假想敌:胄受到等量的无来源生命流失 (【瘫痪】; 出击模式 [ASSUMED], see PART_TRANSFER)
|
||||
const L = leader(b2);
|
||||
if (L) b2.loseHp(L, c.amount * PART_TRANSFER, { source: c.source, from: c.dmg }); // 被击落时受到伤害以一定比例传递给假想敌:胄
|
||||
const src = c.source || c.credit; // a 无来源 burst still passes on (credited)
|
||||
if (L) b2.loseHp(L, c.amount * BLADE_TRANSFER, { source: src && src.side === 'ally' ? src : null, from: c.dmg, sourceless: true });
|
||||
}
|
||||
if (P.state === 'dive' && e2.alive && ++P.hits >= (bb.max_hit_cnt ?? Infinity)) shotDown(b2, e2); // 仅1次
|
||||
},
|
||||
tick(b2, e2, a, dt) {
|
||||
const sp = e2.s.moveSpeed * MOVE_SCALE * dt;
|
||||
if (P.state === 'dive') {
|
||||
const t = P.target;
|
||||
if (!stepToward(e2, t.c, t.r, sp)) return;
|
||||
stunBlast(b2, e2, t.r, t.c, bb.stun ?? 0, bb.dot_damage ?? 0, bb.dot_duration ?? 0, 'bladeDive');
|
||||
P.state = 'return';
|
||||
} else if (P.state === 'return') {
|
||||
if (stepToward(e2, P.home.x, P.home.y, sp)) { P.state = 'hover'; hover(b2, true); }
|
||||
}
|
||||
if (P.state !== 'dive' || !canCast(e2)) return; // a stunned blade does not fly on
|
||||
const t = P.target;
|
||||
if (!stepToward(e2, t.c, t.r, e2.s.moveSpeed * MOVE_SCALE * dt)) return;
|
||||
// arrival: 3×3 stun + DoT (无来源: credited to 胄, as the shell's), then the 初始模式 copy takes over
|
||||
stunBlast(b2, leader(b2) || e2, t.r, t.c, bb.stun ?? 0, bb.dot_damage ?? 0, bb.dot_duration ?? 0, 'bladeDive');
|
||||
replace(b2, e2);
|
||||
},
|
||||
},
|
||||
s && {
|
||||
cd: s.cd, icd: s.icd, cond: (b2) => P.state === 'hover' && opsOnly(allTargets(b2, e)).length > 0,
|
||||
fire(b2, e2) { // fly at the lowest-ATK operator
|
||||
const t = opsOnly(allTargets(b2, e2)).sort((p, q) => p.s.atk - q.s.atk || aggroCmp(p, q))[0];
|
||||
cd: s.cd, icd: s.icd, cond: (b2) => P.state === 'hover' && opsOnly(allTargets(b2, e)).length > 0, // 存在至少1名可选我方干员
|
||||
fire(b2, e2) { // 【掷剑】/【掷锤】 → 出击模式
|
||||
const t = opsOnly(b2.allies()).sort((p, q) => p.s.atk - q.s.atk || aggroCmp(p, q))[0];
|
||||
if (!t) return;
|
||||
P.state = 'dive'; P.hits = 0; P.target = { r: t.tileR, c: t.tileC };
|
||||
hover(b2, false);
|
||||
b2.addBuff(e2, { key: 'boss:sortie', persist: true, flags: { disarm: true } }); // 出击模式:不进行普通攻击
|
||||
const blink = hand ? branchRoute(tpl, `${hand}_blink`) : null;
|
||||
if (blink && Array.isArray(blink.start)) {
|
||||
const from = { x: e2.x, y: e2.y };
|
||||
e2.x = blink.start[1]; e2.y = blink.start[0];
|
||||
b2.fx('blink', { x: e2.x, y: e2.y, id: e2.id, fx: from.x, fy: from.y });
|
||||
}
|
||||
b2.fx('charge', { x: e2.x, y: e2.y, id: e2.id, tx: t.tileC, ty: t.tileR, kind: 'bladeDive' });
|
||||
},
|
||||
},
|
||||
@@ -545,14 +643,16 @@ function kitSpring(ab, e) {
|
||||
},
|
||||
burst(c, b) { if (kind === 'element' && P.up) drop(b); }, // 自身元素损伤爆发时,护盾消失
|
||||
taken(c, b, e2) {
|
||||
const src = c.source || c.credit; // a 无来源 burst still passes on (credited)
|
||||
if (!src || src.side !== 'ally' || !(c.amount > 0)) return;
|
||||
if (!(c.amount > 0)) return;
|
||||
if (c.dmg.tags && c.dmg.tags.includes(SPRING_SHARE_TAG)) return; // a share never passes on again
|
||||
// 【盲信之誓】 受到伤害时以一定比例传递给假想敌:铳 (and, split, to the other springs — 隐秘核心 text)
|
||||
const s0 = c.source || c.credit; // a 无来源 burst still passes on (credited)
|
||||
const src = s0 && s0.side === 'ally' ? s0 : null;
|
||||
// 【盲信之誓】 受到伤害时令假想敌:铳受到等量的无来源生命流失 (PRTS); the even split to the other springs follows the
|
||||
// 隐秘核心 handbook ("传递给<假想敌:铳>和场上其他<“碎铳之簧”>") — not in the PRTS talent, [ASSUMED] amount
|
||||
const g = gun(b);
|
||||
if (g) b.loseHp(g, c.amount * PART_TRANSFER, { source: src, from: c.dmg });
|
||||
if (g) b.loseHp(g, c.amount * PART_TRANSFER, { source: src, from: c.dmg, sourceless: true });
|
||||
const others = b.enemies.filter((o) => o.alive && o !== e2 && isSpring(o));
|
||||
for (const o of others) b.loseHp(o, (c.amount * PART_TRANSFER) / others.length, { source: src, from: c.dmg, tags: [SPRING_SHARE_TAG] });
|
||||
for (const o of others) b.loseHp(o, (c.amount * PART_TRANSFER) / others.length, { source: src, from: c.dmg, tags: [SPRING_SHARE_TAG], sourceless: true });
|
||||
},
|
||||
tick(b, e2, a, dt) {
|
||||
if (!P.up && P.downAt != null && b.time - P.downAt >= regen) raise(b);
|
||||
|
||||
@@ -48,16 +48,17 @@
|
||||
// env_gbuff_new_with_verify act1autochess_debuff_3 {value} 自愈: every damage instance an own unit (operator or
|
||||
// summon) takes heals it `value` (not HP loss 流失, not gauge
|
||||
// fills, not while a 禁疗 status is on)
|
||||
// env_gbuff_new_with_verify act1autochess_debuff_9 {atk, def} 无瑕: operators at full HP get ATK/DEF ×(1 + x)
|
||||
// env_gbuff_new_with_verify act1autochess_debuff_9 {atk, def} 无瑕: operators at full HP get ATK/DEF +x
|
||||
// env_gbuff_new halfIdle_magic_resist_penetration {magic_resist_penetrate_fixed} 火力: operators ignore N RES
|
||||
// env_gbuff_new halfIdle_def_penetration {def_penetrate} 锐利: operators ignore x DEF
|
||||
// char_respawntime_mul {scale} 征召: operators' redeploy time × scale
|
||||
// enemy_attribute_mul / enemy_attribute_add {atk|def|magic_resistance|max_hp|attack_speed|move_speed}
|
||||
// (+ bbStr enemy_level_type ELITE / BOSS) 排斥/责罚/裁决: enemies of the player's half (ownerId)
|
||||
// of that rank get the debuff at spawn (all ranks without a type)
|
||||
// Attribute bonuses are 直接乘算 (support/index.js convention): ×(1 + x) multipliers; flat values stay flat.
|
||||
// Operator attribute bonuses are 直接乘算 (support directMods: additive with every other percentage, PRTS 盟约记录 /
|
||||
// 游戏数据基础); flat values stay flat; the enemies' enemy_attribute_mul stays a multiplier.
|
||||
|
||||
import { gameData, num, buffsOf, passiveBuff, effectRecord } from './support/index.js';
|
||||
import { gameData, num, buffsOf, passiveBuff, effectRecord, directMods } from './support/index.js';
|
||||
import { isShopItem } from '../simdata.js';
|
||||
|
||||
// =====================================================================================================================
|
||||
@@ -305,9 +306,10 @@ export function registerMeta(registry) {
|
||||
|
||||
const RUNE = Object.freeze({ heal: 'act1autochess_debuff_3', flawless: 'act1autochess_debuff_9' });
|
||||
|
||||
/** Operator mods of an env_gbuff blackboard (numbers only; 直接乘算 for attributes). */
|
||||
/** Operator mods of an env_gbuff blackboard (numbers only; 直接乘算 for attributes — support directMods). */
|
||||
function opModsOf(p) {
|
||||
const mods = {};
|
||||
const direct = { atk: 0, def: 0, hp: 0 };
|
||||
const add = (k, v) => { if (Number.isFinite(v) && v !== 0) mods[k] = (mods[k] ?? 0) + v; };
|
||||
const mul = (k, v) => { if (Number.isFinite(v) && v !== 0) mods[k] = (mods[k] ?? 1) * (1 + v); };
|
||||
for (const [k, raw] of Object.entries(p || {})) {
|
||||
@@ -318,15 +320,15 @@ function opModsOf(p) {
|
||||
case 'magic_resist_penetrate': add('resIgnorePct', v); break;
|
||||
case 'def_penetrate': add('defIgnorePct', v); break;
|
||||
case 'def_penetrate_fixed': add('defIgnoreFlat', v); break;
|
||||
case 'atk': mul('atkMul', v); break;
|
||||
case 'def': mul('defMul', v); break;
|
||||
case 'max_hp': mul('hpMul', v); break;
|
||||
case 'atk': direct.atk += v; break;
|
||||
case 'def': direct.def += v; break;
|
||||
case 'max_hp': direct.hp += v; break;
|
||||
case 'attack_speed': add('aspd', Math.abs(v) < 1 ? v * 100 : v); break;
|
||||
case 'damage_scale': mul('dmgDealtMul', v); break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
return mods;
|
||||
return directMods(direct, mods);
|
||||
}
|
||||
|
||||
/** Enemy mods of an enemy_attribute_mul / _add blackboard. */
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
// act1autochess_gar_event_addition_cnt (魔王) layerGain: a 'garrison' gain whose source stands on the tile in front
|
||||
// of 魔王 (range_id 1-1) — or was knocked out there this instant (幽灵鲨 "被击倒时"; engine ctx.tile)
|
||||
// — gets +extra_cnt per bond (the extra does not count toward the source's per-battle cap).
|
||||
// stat traits GAIN_BUFF (atkMul / hpMul), attr_common_global_buff (spRecoveryFlat), attrByBond,
|
||||
// stat traits GAIN_BUFF (battleRuneKey char_attribute_mul "攻击力和生命值+20%": a rune on the chess's base
|
||||
// attributes, so atkMul / hpMul), attr_common_global_buff (spRecoveryFlat; "+X%" ATK / HP would be
|
||||
// 直接乘算), attrByBond ("每叠加N层…+X%": 直接乘算, support directMods),
|
||||
// respawnTimeByBond, attrByBond_add_onstart, ab_damageScaleByBond — layer readers use
|
||||
// floor(Σ layers of the listed ACTIVE bonds / divide_num) and are recomputed after every layer
|
||||
// gain (research 02 §5 [ASSUMED] only active bonds count).
|
||||
@@ -340,8 +342,7 @@ const INSTALLERS = {
|
||||
for (const it of list) {
|
||||
const mods = {};
|
||||
if (S.num(it.bb.sp_recovery_per_sec, 0)) mods.spRecoveryFlat = S.num(it.bb.sp_recovery_per_sec);
|
||||
if (S.num(it.bb.atk, 0)) mods.atkMul = 1 + S.num(it.bb.atk);
|
||||
if (S.num(it.bb.max_hp, 0)) mods.hpMul = 1 + S.num(it.bb.max_hp);
|
||||
Object.assign(mods, S.directMods({ atk: S.num(it.bb.atk, 0), hp: S.num(it.bb.max_hp, 0) }));
|
||||
if (S.num(it.bb.attack_speed, 0)) mods.aspd = S.num(it.bb.attack_speed);
|
||||
S.passiveBuff(battle, it.unit, `gar:${it.gid}`, mods);
|
||||
}
|
||||
@@ -369,10 +370,8 @@ export function stepsOf(battle, it) {
|
||||
}
|
||||
|
||||
function attrMods(bb, k) {
|
||||
const m = {};
|
||||
if (S.num(bb.atk, 0)) m.atkMul = 1 + S.num(bb.atk) * k;
|
||||
if (S.num(bb.max_hp, 0)) m.hpMul = 1 + S.num(bb.max_hp) * k;
|
||||
if (S.num(bb.def, 0)) m.defMul = 1 + S.num(bb.def) * k;
|
||||
// "每叠加N层,本干员攻击力+X%": a 直接乘算 bonus like every other "+X%" of the 卫戍 systems (support directMods)
|
||||
const m = S.directMods({ atk: S.num(bb.atk, 0) * k, hp: S.num(bb.max_hp, 0) * k, def: S.num(bb.def, 0) * k });
|
||||
if (S.num(bb.attack_speed, 0)) m.aspd = S.num(bb.attack_speed) * k;
|
||||
if (S.num(bb.hp_recovery_per_sec, 0)) m.hpRegen = S.num(bb.hp_recovery_per_sec) * k;
|
||||
if (S.num(bb.sp_recovery_per_sec, 0)) m.spRecoveryFlat = S.num(bb.sp_recovery_per_sec) * k;
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
// Numbers always come from the concrete record's buffs (support.buffsOf: `{ ...bb, ...bbStr }` per buff, looked up by
|
||||
// the buff's bbStr.key — the flattened `params` of data/items.json lose duplicate keys such as 蒸汽之心 attack_speed).
|
||||
//
|
||||
// Stat rule (research 02 §2.1, PRTS "直接乘算"; research 04 §2 assumed additive %): every equipment percentage is its own
|
||||
// multiplier on the final stat — atk/def/max_hp → atkMul/defMul/hpMul = 1 + x; attack_speed → aspd (additive);
|
||||
// Stat rule (PRTS 盟约记录 "装备效果提供的属性加成均为直接乘算"; 直接乘算 = summed with every other percentage, PRTS 游戏数据
|
||||
// 基础 — research 04 §2's additive reading; v2.5 had each item its own multiplier): atk/def/max_hp → support directMods
|
||||
// (atkPct/defPct/hpPct += x; constants.js DIRECT_BONUS_STACKING); attack_speed → aspd (additive);
|
||||
// magic_resistance → resFlat; respawn_time → redeployMul 1 + x; sp_recovery_per_sec → spRecoveryFlat;
|
||||
// magic_resist_penetrate → resIgnorePct; taunt_level → taunt. Two different items on one unit both apply, and a
|
||||
// normal + golden copy of the same item may coexist (different ids → different buff keys) and both apply.
|
||||
@@ -33,7 +34,7 @@
|
||||
|
||||
import {
|
||||
num, itemRecord, itemKeyOf, buffsOf, isOp, onField, unitBonds, isMember, bondActive, isGroundOp, frontTile,
|
||||
alliesAround, passiveBuff, fxOn, battleStore, contentInfo, itemsOf,
|
||||
alliesAround, passiveBuff, fxOn, battleStore, contentInfo, itemsOf, directMods,
|
||||
} from '../support/index.js';
|
||||
import { mitigate, hasHp } from '../../damage.js';
|
||||
|
||||
@@ -68,9 +69,8 @@ function statMods(p) {
|
||||
const m = {};
|
||||
let any = false;
|
||||
const set = (k, v) => { m[k] = v; any = true; };
|
||||
if (num(p.atk)) set('atkMul', 1 + num(p.atk));
|
||||
if (num(p.def)) set('defMul', 1 + num(p.def));
|
||||
if (num(p.max_hp)) set('hpMul', Math.max(0.01, 1 + num(p.max_hp)));
|
||||
// ATK / DEF / max HP "+X%" are 直接乘算 (support directMods: additive with every other percentage)
|
||||
for (const [k, v] of Object.entries(directMods({ atk: num(p.atk), def: num(p.def), hp: num(p.max_hp) ? Math.max(-0.99, num(p.max_hp)) : 0 }))) set(k, v);
|
||||
if (num(p.attack_speed)) set('aspd', num(p.attack_speed));
|
||||
if (num(p.magic_resistance)) set('resFlat', num(p.magic_resistance));
|
||||
if (num(p.respawn_time)) set('redeployMul', Math.max(0, 1 + num(p.respawn_time)));
|
||||
@@ -366,7 +366,7 @@ const BY_ITEM = {
|
||||
if (Math.hypot(c.target.x - u.x, c.target.y - u.y) >= r - 1e-6) c.dmg.mul *= sc;
|
||||
});
|
||||
},
|
||||
// 炎国短刀: each skill activation +atk (≤ atk_buff_cnt stacks) — one multiplier 1 + atk × stacks
|
||||
// 炎国短刀: each skill activation +atk (≤ atk_buff_cnt stacks) — one 直接乘算 bonus of atk × stacks
|
||||
chess_item_3_04_e(battle, u, rec, S) {
|
||||
const p = bp(rec, 'act1vautochess_equip_acarm024_global_buff');
|
||||
if (!p) return;
|
||||
@@ -375,7 +375,7 @@ const BY_ITEM = {
|
||||
S.on('skillStart', (c) => {
|
||||
if (c.unit !== u || n >= cap || !chance(battle, num(p.prob, 1))) return;
|
||||
n++;
|
||||
S.stat('stacks', { atkMul: 1 + num(p.atk) * n });
|
||||
S.stat('stacks', directMods({ atk: num(p.atk) * n }));
|
||||
});
|
||||
},
|
||||
// 迅捷作战粮: on deploy SP += sp_each_person × (1 + other operators sharing a bond)
|
||||
@@ -401,7 +401,7 @@ const BY_ITEM = {
|
||||
chess_item_3_06_e(battle, u, rec, S) {
|
||||
const p = bp(rec, 'act1autochess_equip_acarm049_global_buff');
|
||||
if (!p) return;
|
||||
if (num(p.init_max_hp)) S.stat('hp', { hpMul: 1 + num(p.init_max_hp) });
|
||||
if (num(p.init_max_hp)) S.stat('hp', directMods({ hp: num(p.init_max_hp) }));
|
||||
const ex = num(p.ex_max_hp);
|
||||
if (!ex) return;
|
||||
const key = S.key('front');
|
||||
@@ -413,7 +413,7 @@ const BY_ITEM = {
|
||||
const here = initial ? a.homeR === fr && a.homeC === fc : onField(a) && a.tileR === fr && a.tileC === fc;
|
||||
if (here) { occupied = true; break; }
|
||||
}
|
||||
if (!occupied) S.buff(u, { key, mods: { hpMul: 1 + ex }, refresh: 'replace' });
|
||||
if (!occupied) S.buff(u, { key, mods: directMods({ hp: ex }), refresh: 'replace' });
|
||||
else battle.removeBuff(u, key);
|
||||
};
|
||||
S.on('deploy', (c) => { if (c.unit === u) check(!!c.initial); });
|
||||
@@ -675,7 +675,7 @@ const BY_ITEM = {
|
||||
if (partner && carries(battle, u, partner) && c.skill && c.skill.isTimed) {
|
||||
combo = true;
|
||||
doomed = false;
|
||||
S.buff(u, { key: comboKey, mods: { atkMul: 1 + num(p.atk) } });
|
||||
S.buff(u, { key: comboKey, mods: directMods({ atk: num(p.atk) }) });
|
||||
}
|
||||
});
|
||||
S.on('skillEnd', (c) => {
|
||||
@@ -715,7 +715,7 @@ const BY_ITEM = {
|
||||
const stealth = !!u.s.flags.stealth;
|
||||
if (stealth && st.bonus < max) {
|
||||
st.bonus = Math.min(max, st.bonus + per * TICKS);
|
||||
S.buff(u, { key, mods: { atkMul: 1 + st.bonus }, refresh: 'replace' });
|
||||
S.buff(u, { key, mods: directMods({ atk: st.bonus }), refresh: 'replace' });
|
||||
}
|
||||
if (st.wasStealth && !stealth && st.bonus > 0) st.armed = true;
|
||||
st.wasStealth = stealth;
|
||||
|
||||
@@ -109,6 +109,8 @@ function inspire(battle, target, val, src, stat = 'atk') {
|
||||
battle.addBuff(target, { key, mods: stat === 'hp' ? { hpFlat: flat } : { atkFlat: flat }, duration: AURA_DUR, source: src, visible: true, data: { src: src.id, val } });
|
||||
}
|
||||
/** Reveal stealthed enemies on the given tiles. */
|
||||
/** RES cut mods for a blackboard magic_resistance value (battle.applyStrongest): |v| < 1 = ×(1 + v), else flat v. */
|
||||
const resCut = (v) => (Math.abs(v) < 1 ? { resMul: Math.max(0, 1 + v) } : { resFlat: v });
|
||||
const reveal = (battle, enemies) => { for (const e of enemies) if (e.s.flags.stealth) pulse(battle, e, 'aura:reveal', null, { flags: { reveal: true } }); };
|
||||
/**
|
||||
* Knock-back of 力度 `force` away from `from` (a radial push, Battle.push: the official 力度 − 重量 distance — PRTS 推与拉;
|
||||
@@ -1077,7 +1079,8 @@ const kits = {
|
||||
for (const e of enemiesOnRange(battle, unit)) {
|
||||
const f = e.s.flags;
|
||||
const m = f.freeze ? num(t0.damage_scale_freeze, 1.5) : f.cold ? num(t0.damage_scale_cold, 1.25) : 0;
|
||||
if (m) battle.addBuff(e, { key: `gnosis:fragile:${unit.id}`, mods: { dmgTakenMul: m }, duration: 0.15 });
|
||||
// 同名效果取最高: one 坚冰 per enemy, the strongest — two 灵知 (a pair / 联防 partner's copy) never compound
|
||||
if (m) battle.applyStrongest(e, 'gnosis:fragile', { duration: 0.15, value: m, mods: (v) => ({ dmgTakenMul: v }), source: unit });
|
||||
}
|
||||
});
|
||||
} },
|
||||
@@ -1105,10 +1108,10 @@ const kits = {
|
||||
kind: (def.skill?.maxCharges ?? 1) > 1 ? 'charges' : 'instant',
|
||||
targeting: g ? { rangeGrid: g } : undefined,
|
||||
onStart({ battle, unit }) {
|
||||
const debuff = Math.abs(mr) < 1 ? { resMul: Math.max(0, 1 + mr) } : { resFlat: mr };
|
||||
for (const e of targetsInRange(battle, unit)) {
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1.7), type: 'arts', isSkill: true, tags: ['skill', 'burst'] });
|
||||
if (e.alive && mr) battle.addBuff(e, { key: `lionhd:res:${unit.id}`, mods: debuff, duration: num(bb.duration, 6), source: unit });
|
||||
// 同名效果取最高: one RES cut per enemy (the strongest), never one per 莱恩哈特
|
||||
if (e.alive && mr) battle.applyStrongest(e, 'lionhd:res', { duration: num(bb.duration, 6), value: mr, mods: resCut, source: unit });
|
||||
}
|
||||
battle.fx('explosion', { x: unit.x, y: unit.y, id: unit.id });
|
||||
},
|
||||
@@ -1179,10 +1182,10 @@ const kits = {
|
||||
talentAtk(battle, unit);
|
||||
battle.addBuff(unit, { key: 'texas2:shower', duration: dur, mods: { atkPct: num(bb.atk) }, visible: true });
|
||||
const mr = num(bb.magic_resistance, 0);
|
||||
const debuff = Math.abs(mr) < 1 ? { resMul: Math.max(0, 1 + mr) } : { resFlat: mr };
|
||||
for (const e of targetsInGrid(battle, unit, g)) {
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1.5), type: 'arts', isSkill: true, tags: ['skill', 'burst'] });
|
||||
if (e.alive && mr) battle.addBuff(e, { key: `texas2:resDown:${unit.id}`, mods: debuff, duration: num(bb.debuff_duration, 8), source: unit });
|
||||
// 同名效果取最高: one RES cut per enemy (the strongest), never one per 缄默德克萨斯
|
||||
if (e.alive && mr) battle.applyStrongest(e, 'texas2:resDown', { duration: num(bb.debuff_duration, 8), value: mr, mods: resCut, source: unit });
|
||||
}
|
||||
battle.fx('swordStorm', { x: unit.x, y: unit.y, id: unit.id });
|
||||
};
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
// never edit it; module-specific helpers live in the submodules.
|
||||
//
|
||||
// Conventions shared by every wave-B module (details in docs/CONTENT.md):
|
||||
// * Attribute bonuses from bonds, bands, equipment and 机变 cards are "直接乘算" (research 02 §2.1): each source is its
|
||||
// own multiplier on the final stat → `atkMul` / `defMul` / `hpMul` (1 + x), never the skill-style `atkPct`.
|
||||
// ASPD stays additive (`aspd`), damage bonuses are `dmgDealtMul`, redeploy changes `redeployMul`.
|
||||
// * Attribute bonuses ("+X%" ATK / DEF / max HP) from bonds, bands, equipment, 机变 cards and the per-layer 特质 are
|
||||
// "直接乘算" (PRTS 盟约记录; PRTS 游戏数据基础: 直接乘算 values are summed with every other 直接乘算, a skill's "攻击力+X%"
|
||||
// included) → `directMods({ atk, def, hp })` = the additive `atkPct` / `defPct` / `hpPct` (constants.js
|
||||
// DIRECT_BONUS_STACKING; 'multiply' restores the v2.5 per-source ×(1 + x) `atkMul`). "提升至X%" effects stay
|
||||
// multipliers. ASPD stays additive (`aspd`), damage bonuses are `dmgDealtMul`, redeploy changes `redeployMul`.
|
||||
// * Match-long passives use `passiveBuff()` (persist + allowDead + replace): idempotent by key, survive death.
|
||||
// * Buff keys / fx `src` are namespaced: `bond:<bondId>…`, `item:<itemKey>…`, `gar:<effectKey>…`, `band:<bandId>…`,
|
||||
// `choice:<effectId>…`.
|
||||
@@ -16,7 +18,7 @@
|
||||
// `PlayerBattleInput.contentInfo`, written by the `global:contentb_info` meta handler (support/meta.js).
|
||||
|
||||
import { getData } from '../../../data.js';
|
||||
import { COLS } from '../../constants.js';
|
||||
import { COLS, DIRECT_BONUS_STACKING } from '../../constants.js';
|
||||
import { frontOf, offsetTile } from '../../dir.js';
|
||||
import { bodyInKeys, bodyDist, bodyInRadius, bodyOnTile, bodyTileReach } from '../../body.js';
|
||||
|
||||
@@ -267,6 +269,25 @@ export const inRange = (u, target) => !!(u && target && u.rangeKeySet && onKeys(
|
||||
// =====================================================================================================================
|
||||
// battle: buffs, fx, per-battle state
|
||||
|
||||
/**
|
||||
* Buff mods of a 直接乘算 attribute bonus (bonds, bands, equipment, 机变 cards, per-layer 特质): ratios `atk` / `def` /
|
||||
* `hp` (+0.3 = "+30%"; negative lowers) → `atkPct` / `defPct` / `hpPct`, summed by the engine with every other additive
|
||||
* percentage (constants.js DIRECT_BONUS_STACKING 'add', the official rule); 'multiply' → `atkMul` / `defMul` / `hpMul`
|
||||
* = 1 + x (the v2.5 reading). Zero / non-finite parts are left out; `extra` mods are merged in.
|
||||
*/
|
||||
export function directMods({ atk = 0, def = 0, hp = 0 } = {}, extra = null) {
|
||||
const m = {};
|
||||
const put = (pct, mul, v) => {
|
||||
if (!(typeof v === 'number' && Number.isFinite(v)) || v === 0) return;
|
||||
if (DIRECT_BONUS_STACKING === 'multiply') m[mul] = Math.max(0, 1 + v);
|
||||
else m[pct] = v;
|
||||
};
|
||||
put('atkPct', 'atkMul', atk);
|
||||
put('defPct', 'defMul', def);
|
||||
put('hpPct', 'hpMul', hp);
|
||||
return extra ? Object.assign(m, extra) : m;
|
||||
}
|
||||
|
||||
/** Match-long passive buff (survives death/redeploy; re-adding the same key replaces it — idempotent). */
|
||||
export function passiveBuff(battle, u, key, mods, extra = {}) {
|
||||
return battle.addBuff(u, { key, mods, persist: true, allowDead: true, refresh: 'replace', ...extra });
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// dealDamage order: (element → gauge path) | invulnerable? → 'hit' hook (mutable DamageInfo, may set cancel)
|
||||
// → dodge (phys/arts, canDodge) → mitigation (phys: DEF, arts: RES, true: none)
|
||||
// → × source dmgDealtMul (× phys/artsDealtMul) × target dmgTakenMul (not for 元素伤害) × type-taken mul × dmg.mul
|
||||
// → 限伤 (leaders in boss / hidden battles: a hit of ceil(final) ≥ BOSS_HIT_LIMIT is cancelled, see leaderHitCancelled)
|
||||
// → shields (hit-negating barriers first, then HP shields) → HP loss (boss pool routing) → 'damaged' hook
|
||||
// → SP-on-hurt / TAKE_DAMAGE trigger → fatal/kill.
|
||||
// Phys: max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 5 %·A); Arts: max(A×(1 − R′/100), 5 %·A) with
|
||||
@@ -45,6 +46,7 @@
|
||||
// has `hitSleep` or the damage carries `ignoreSleep`.
|
||||
|
||||
import { MIN_DAMAGE_RATIO, ELEMENT, ELEMENT_ORDER, PALSY_MAX } from './constants.js';
|
||||
import { BOSS_HIT_LIMIT } from '../../shared/constants.js';
|
||||
|
||||
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
|
||||
@@ -112,6 +114,26 @@ export function mitigate(amount, type, target, ign = {}) {
|
||||
return amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 限伤 — the official boss-hit limit (shared/constants.js BOSS_HIT_LIMIT, docs/research/11-limits-official.md §2;
|
||||
* client `AutoChessStepModeManager._OnBossEnemyTakeDamage`: `d = ceil(value); if (d >= 300000) modifier.Cancel()`).
|
||||
* True when the hit of `amount` HP about to land on `target` is cancelled: the target is a leader (`isBoss`: the
|
||||
* tag-'boss' units — data/bosses.json enemyKey, the official IsBossEnemy list — and their mirrored copies; never a part,
|
||||
* escort, drone or other minion), the battle is a boss / hidden one (the client's step mode: every boss battle outside
|
||||
* training) and ceil(amount) ≥ BOSS_HIT_LIMIT. The cancel is whole (no clamp): no HP / pool loss, no credit, no damage
|
||||
* number. A 'hitCap' fx event `{ id, n: ceil(amount) }` marks it for the client, which draws nothing — the official
|
||||
* shows no number [ASSUMED]. Every HP-damage kind is checked as the official `modifier.isDamage` (phys, arts, true,
|
||||
* 元素伤害 incl. element bursts, DoT ticks — they all come through dealDamage — and losses passed on to a leader through
|
||||
* Battle.loseHp); element 损伤 (gauge fill, 'element') removes no HP and is never checked. Deterministic (Math.ceil of
|
||||
* the same double on every engine).
|
||||
*/
|
||||
export function leaderHitCancelled(battle, target, amount) {
|
||||
if (!(BOSS_HIT_LIMIT > 0) || !target || !target.isBoss || (battle.kind !== 'boss' && battle.kind !== 'hidden')) return false;
|
||||
if (!(Math.ceil(amount) >= BOSS_HIT_LIMIT)) return false;
|
||||
battle.fx('hitCap', { x: target.x, y: target.y, id: target.id, n: Math.ceil(amount) });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Absorb damage with shields on `target`. Returns the remaining amount. */
|
||||
export function absorbShields(battle, target, amount) {
|
||||
if (amount <= 0) return 0;
|
||||
@@ -196,6 +218,9 @@ export function dealDamage(battle, source, target, dmgIn) {
|
||||
mul *= type === 'phys' ? ts.physTakenMul : type === 'arts' ? ts.artsTakenMul : type === 'elemental' ? ts.elementalTakenMul : ts.trueTakenMul;
|
||||
final *= mul;
|
||||
if (!(final > 0) || !Number.isFinite(final)) final = 0;
|
||||
// 限伤: a leader's hit of ≥ BOSS_HIT_LIMIT in a boss / hidden battle is cancelled before it reaches shields / HP — what
|
||||
// ran before it (the attack, its SP, `hit` hook effects, separate element 损伤) stays; nothing after it happens
|
||||
if (final > 0 && leaderHitCancelled(battle, target, final)) return 0;
|
||||
final = absorbShields(battle, target, final);
|
||||
return applyHpLoss(battle, source, target, final, dmg);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user