0.2.5-dev: community round 30 and follow-ups

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

No files matched your search

+1
View File
@@ -373,6 +373,7 @@ export class Match {
this.sp = null;
this.wave = null;
this.bossWaves = null;
this.bossPairs = null; // drawn once per match; the hidden round retains partners and sides
this.fields = [];
this.runner = null;
/** playerId → fieldId */
+12 -4
View File
@@ -30,7 +30,7 @@
// units > seat, research 08 §5); leakers = players with counted leaks
// settle no unite ⇒ loss = min(cap, counted leaks); after 联防 a leaker loses ≤ cap (its leaked enemies'
// offspring count too), everybody else ≤ min(cap, own counted leaks); LP ≤ 0 ⇔ eliminated
// final assault fields pair alive players by seat, team LP = Σ alive LP, boss pool = bossPoolHp(); hidden core only
// final assault fields follow the saved per-match pairs, team LP = Σ alive LP, boss pool = bossPoolHp(); hidden core only
// after a win when hiddenEligible() holds
// result each title ≤ once, ≤ 1 title per player, onlyOnWin titles only on a win, roundsPassed per player,
// Σ alive players' LP = the merged team LP after the Final Assault
@@ -44,7 +44,7 @@
import { PHASE } from '../../shared/constants.js';
import { collectViolations } from './invariants.js';
import { mergeTile, pieceDir, canPlace, placeClass } from './board.js';
import { pairPlayers, bossPoolHp, hiddenEligible } from './finalAssault.js';
import { bossPoolHp, hiddenEligible } from './finalAssault.js';
import { helperOrder } from './unite.js';
import { BAND_TURN_SECONDS } from './Match.js';
@@ -449,15 +449,23 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
});
wrap(m, 'startFinalAssault', function (orig, hidden) {
const alive = m.alivePlayers();
const before = m.bossPairs?.map(g => g.slice()) ?? null;
const lpSum = alive.reduce((s, p) => s + Math.max(0, p.lp), 0);
const teamLp0 = m.teamLp;
const res = orig(hidden);
runInvariants();
check('final assault', () => {
if (!alive.length) return;
const groups = pairPlayers(alive).map((g) => g.map((p) => p.playerId).join(','));
const pairs = m.bossPairs || [];
const memberIds = pairs.flat();
if (pairs.length !== Math.ceil(alive.length / 2) || pairs.some(g => g.length < 1 || g.length > 2)
|| new Set(memberIds).size !== memberIds.length
|| JSON.stringify([...memberIds].sort()) !== JSON.stringify(alive.map(p => p.playerId).sort())) fail('boss pairing does not cover every alive player once');
if (before && before.flat().every(id => alive.some(p => p.playerId === id)) && before.flat().length === alive.length
&& JSON.stringify(before) !== JSON.stringify(pairs)) fail('boss pairing changed after preparation');
const groups = pairs.map(g => g.join(','));
const fields = m.fields.map((f) => f.players.join(','));
if (JSON.stringify(groups) !== JSON.stringify(fields)) fail(`boss fields ${fields.join(' | ')} != seat pairs ${groups.join(' | ')}`);
if (JSON.stringify(groups) !== JSON.stringify(fields)) fail(`boss fields ${fields.join(' | ')} != saved pairs ${groups.join(' | ')}`);
m.fields.forEach((f, i) => { if (f.fieldId !== `b${i + 1}`) fail(`boss field id ${f.fieldId}`); });
if (!hidden && m.teamLp !== lpSum) fail(`team LP ${m.teamLp} != Σ alive LP ${lpSum}`);
if (hidden && m.teamLp !== teamLp0) fail(`hidden core changed team LP ${teamLp0} → ${m.teamLp}`);
+2
View File
@@ -480,6 +480,7 @@ export function makeCtx(m, ps, source, hook, ev = null) {
lp: () => ps.lp,
alive: () => ps.alive,
bandId: () => ps.bandId,
teamMedicGranted: () => m.teamMedicGranted(),
shopLevel: () => ps.shop.level,
bond: (id) => (ps.bonds[id] ? { ...ps.bonds[id] } : null),
bonds: () => Object.fromEntries(Object.entries(ps.bonds).map(([k, v]) => [k, { ...v }])),
@@ -569,6 +570,7 @@ export function makeCtx(m, ps, source, hook, ev = null) {
source: opts.source || source.key || 'effect',
toTemp: !!opts.toTemp,
deferMerge: ps._deferItemMerge > 0,
requirePool: opts.requirePool !== false,
});
return p ? view(p) : null;
},
+16 -4
View File
@@ -3,7 +3,8 @@
//
// * Merged LP: teamLp = Σ LP of the alive players at the start of the Final Assault (no cap); the Hidden Core
// continues with what is left.
// * Pairing: alive players by seat → (1,2), (3,4); an odd player is alone on its own field with the `_s` template
// * Pairing: shuffled once per match before Final Assault prep; Hidden Core keeps those pairs. If players leave,
// intact pairs stay together and the remaining single players pair up. An odd player uses the `_s` template
// (solo modes always use `_s`). Field ids 'b1', 'b2'. In a pair the first player is the LEFT side, the second the
// RIGHT side (the sim mirrors the right side: board col c → field col 20 − c with the piece direction RIGHT ↔
// LEFT, UP / DOWN unchanged (DESIGN §3, research 09 §1.2 ConvertChessPositionInfoToBossMap); board rows 9–12 →
@@ -55,11 +56,22 @@ export function bossFieldPlacement(side, row, col, dir = 'RIGHT') {
return side === 'R' ? { row: r, col: COLS - 1 - col, dir: mirrorDir(d) } : { row: r, col, dir: d };
}
/** Pair alive players by seat: [[a, b], [c, d]] / [[a, b], [c]] / [[a]]. */
export function pairPlayers(alive) {
/** Seeded initial pairing; `previous` carries player-id pairs through prep, combat and Hidden Core. */
export function pairPlayers(alive, rng, previous = null) {
const sorted = alive.slice().sort((a, b) => a.seat - b.seat);
const groups = [];
for (let i = 0; i < sorted.length; i += 2) groups.push(sorted.slice(i, i + 2));
let singles;
if (Array.isArray(previous)) {
const remaining = new Map(sorted.map(p => [p.playerId, p]));
singles = [];
for (const ids of previous) {
const group = [];
for (const id of ids) if (remaining.has(id)) { group.push(remaining.get(id)); remaining.delete(id); }
if (group.length === 2) groups.push(group); else singles.push(...group);
}
singles.push(...remaining.values());
} else singles = sorted.length > 1 ? rng.shuffle(sorted) : sorted;
for (let i = 0; i < singles.length; i += 2) groups.push(singles.slice(i, i + 2));
return groups;
}
+7
View File
@@ -100,6 +100,10 @@ export class GameData {
const chess = this.raw.chess && typeof this.raw.chess === 'object' ? this.raw.chess : {};
this._chess = chess;
this._items = this.raw.items && typeof this.raw.items === 'object' ? this.raw.items : {};
// Fixed operator gifts are unlimited outputs, but every produced normal/advanced item still occupies 1/2 copies
// (card-pool experiment BV1eLXXBqEgF, maintainer-confirmed 2026-10-11). Random subset gifts are not exempt.
this._fixedGiftItems = new Set(Object.values(this.raw.garrisons || {})
.filter(g => g.effectKey === 'SERVER_GAIN_EQUIP').map(g => g.bbStr?.chess).filter(id => this._items[id]));
this._bonds = this.raw.bonds && typeof this.raw.bonds === 'object' ? this.raw.bonds : {};
/** visible, shop-eligible base (normal) chess ids */
this.visibleChess = Object.keys(chess).filter((id) => {
@@ -312,6 +316,9 @@ export class GameData {
return Number.isInteger(count) && count >= 0 ? count : null;
}
/** Whether a fixed trait gift may be produced beyond stock (including the advanced version of that item). */
isUnlimitedGiftItem(id) { return this._fixedGiftItems.has(this.baseIdOf(id)); }
/** Copies needed to merge (0 = never merges: golden chess). */
mergeCount(id) {
const c = this.chess(id);
+3 -2
View File
@@ -202,14 +202,15 @@ export function collectViolations(m, { limit = 25 } = {}) {
}
for (const [base, n] of held) if (!m.pool.has(base) && n !== 0) fail(`non-pool chess ${base} holds ${n} copies`);
// Shared shop equipment. The chess pool above may go negative (Mimic). Item stock may not: held ≤ cap.
// Fixed operator gifts may overdraw equipment stock; ownership still counts and keeps shop availability at zero.
// Other item families retain their cap. The chess pool above also permits explicit overdraw (Mimic).
if (m.itemPool && gd.raw.items) {
for (const [id, rec] of Object.entries(gd.raw.items)) {
if (!rec || rec.isGolden) continue;
const cap = m.itemPool.cap(id);
if (!Number.isInteger(cap)) continue;
const h = m.itemPool.held(id);
if (h > cap) fail(`item ${id}: held ${h} > cap ${cap}`);
if (h > cap && !gd.isUnlimitedGiftItem(id)) fail(`item ${id}: held ${h} > cap ${cap}`);
}
}
+3 -2
View File
@@ -6,7 +6,7 @@
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
import { PHASE, GEO } from '../../../shared/constants.js';
import { deriveSeed } from '../../sim/rng.js';
import { createRng, deriveSeed } from '../../sim/rng.js';
import { buildBossWave, bountySpawns } from '../waves.js';
import { pairPlayers, bossPoolHp, SharedBossPool, CreditPool, hiddenEligible, BOSS_HIT_STEPS } from '../finalAssault.js';
import { FieldRunner, HeadlessPacer, syntheticResult, BOSS_SILENCE_MS, CATCHUP_TICKS_PER_INTERVAL } from '../fields.js';
@@ -66,7 +66,8 @@ export class MatchBoss {
},
});
this.bossPool = pool;
const groups = pairPlayers(alive);
const groups = pairPlayers(alive, createRng(deriveSeed(this.seed, 'boss-pairs')), this.bossPairs);
this.bossPairs = groups.map(g => g.map(p => p.playerId));
const reuse = this.bossWaves && this.bossWaves.length === groups.length && this.bossWaves.every((w, i) => w.players.join() === groups[i].map((p) => p.playerId).join());
this.fields = groups.map((g, i) => {
const solo = this.isSolo || g.length === 1;
+4 -1
View File
@@ -88,8 +88,11 @@ export class MatchInfra {
}
humans() { return this.order.filter((p) => !p.isBot && !p.left); }
/** [ASSUMED] Touch's initial, match-long team grant survives its holder's elimination/quit; focus alone grants none. */
teamMedicGranted() { return this.order.some(p => p.bandId === 'band_amedic'); }
/**
* The boss-round group of a player (`bossWaves`: the seat pairs of finalAssault.js pairPlayers, planned in startRound
* The boss-round group of a player (`bossWaves`: the per-match random pairs, planned in startRound
* before the players' round start) and its side — 'L', or 'R' for the second player of a pair (the mirrored right
* half) — or null outside a boss round / for a player without a field.
* @returns {{ wave: object, players: string[], side: 'L'|'R' } | null}
+6 -2
View File
@@ -7,6 +7,7 @@
import { PHASE, ERR } from '../../../shared/constants.js';
import { buildNormalWave, buildBossWave } from '../waves.js';
import { pairPlayers } from '../finalAssault.js';
import { createRng, deriveSeed } from '../../sim/rng.js';
import { botPickBand } from '../bot.js';
import { OK, fail, DELAYS, BAND_TURN_SECONDS } from './common.js';
@@ -288,11 +289,14 @@ export class MatchPhases {
for (const ps of this._viewers()) this._followScout(ps);
}
/** The boss round's fields (seat pairs of the alive players) and their templates, generated for the prep preview. */
/** Stable per-match pairs and their templates, generated before the prep preview / board validation. */
_planBossWaves() {
const r = this.round;
const bossId = r === this.gd.hiddenRound && r !== this.gd.bossRound ? this.hiddenBossId : this.bossId;
this.bossWaves = pairPlayers(this.alivePlayers()).map((g) => ({
// Pairing uses an independent seeded draw, consuming no shop, wave, draft or battle randomness.
const groups = pairPlayers(this.alivePlayers(), createRng(deriveSeed(this.seed, 'boss-pairs')), this.bossPairs);
this.bossPairs = groups.map(g => g.map(p => p.playerId));
this.bossWaves = groups.map((g) => ({
players: g.map((p) => p.playerId),
wave: buildBossWave(this.gd, this.rngWaves, this.factions, r, { bossId, solo: this.isSolo || g.length === 1 }),
}));
+2 -1
View File
@@ -86,6 +86,7 @@ export class MatchViews {
hiddenBossId: this.hiddenBossId,
bossRound: this.gd.bossRound,
hiddenRound: this.gd.hiddenRound,
bossPairs: this.bossWaves ? this.bossWaves.map(w => w.players.slice()) : this.bossPairs ? this.bossPairs.map(ids => ids.slice()) : null,
spRound: this.gd.spRounds().includes(this.round),
// DESIGN §14: 'client' = battles are simulated by the browsers (b.start specs), 'server' = legacy streaming
combatMode: this.clientCombat ? 'client' : 'server',
@@ -248,7 +249,7 @@ export class MatchViews {
* battleStart, so the prep view always uses the reserve medic, regardless of the current elite count. The stage's
* normal-board slot is transformed to the correct half of a boss field by _onBossHalf / the client prep camera. */
prepMapChars(ps) {
if (!ps?.alive || this.phase !== PHASE.PREP || !this.order.some((p) => p.alive && p.bandId === 'band_amedic')) return [];
if (!ps?.alive || this.phase !== PHASE.PREP || !this.teamMedicGranted()) return [];
const id = 'char_605_cmedic';
const slot = this.stage?.mapChars?.find((m) => m?.key === id && Array.isArray(m.pos) && m.pos[0] >= 9 && m.pos[0] <= 12
&& m.pos[1] >= 0 && m.pos[1] <= 10 && !/multi_only/.test(String(m.alias)));
+5 -2
View File
@@ -285,10 +285,13 @@ export class PlayerAcquire {
* already equipped is taken off for the fight about to start. Hand and temp both full keeps the 「整备区已满,获得的装备已销毁」
* outcome. [ASSUMED] every item granted at 休整期结束, not only 维多利亚's 战栗维式重锤 (owner's decision 2026-10-04).
*/
acquireItem(itemId, { source = 'grant', toTemp = false, silent = false, deferMerge = false } = {}) {
acquireItem(itemId, { source = 'grant', toTemp = false, silent = false, deferMerge = false, requirePool = true } = {}) {
const rec = this.gd.item(itemId);
if (!rec) return null;
if (!this.m.itemPool.canGain(itemId)) {
// Only the fixed operator-gift families can overdraw. Several unrelated strategy grants also pass
// requirePool:false; they must retain their stock limit (for example commercial packaging).
const unlimitedGift = !requirePool && this.gd.isUnlimitedGiftItem(itemId);
if (!unlimitedGift && !this.m.itemPool.canGain(itemId)) {
this.m.toast(this, 'warn', '装备库存不足,未获得该装备');
return null;
}
+8 -5
View File
@@ -4,7 +4,7 @@
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
// the player state).
import { ERR, layerGainRoom } from '../../../shared/constants.js';
import { ERR, PHASE, layerGainRoom } from '../../../shared/constants.js';
import { freeSlot } from '../board.js';
import { OK, fail } from './common.js';
@@ -47,6 +47,10 @@ export class PlayerEconomy {
if (add <= 0) return 0;
this.layers[bondId] = before + add;
this.recompute();
// A live prep gain, not a view delta: reconnects and combat-result backfill must not replay the cue.
if ([PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.PREP].includes(this.m.phase)) {
this.m.sendTo(this.playerId, { t: 'm.layer', playerId: this.playerId, bondId, add });
}
this.m.dispatch(this, 'onLayers', { bondId, from: before, to: before + add, reason });
return add;
}
@@ -141,10 +145,9 @@ export class PlayerEconomy {
const rec = this.gd.chess(slot.id);
// a DIY slot sells only as this player's own 自选 piece (a slotted slot's record carries `diyFor`)
if (!rec || (rec.isDiy && !rec.diyFor)) return fail(ERR.BAD_TARGET);
const base = this.gd.baseIdOf(slot.id);
const need = rec.isGolden ? this.gd.goldenCopies : 1;
const pool = this.poolOf(base);
if (pool.has(base) && pool.left(base) < need) return fail(ERR.SOLD_OUT);
// Already displayed operators stay purchasable after stock runs out (#498; 路标月报#2,
// BV1eLXXBqEgF 2:50–2:57: nine owned + four shop offers => thirteen owned).
// acquireChess records every copy, including the deficit; future rolls still exclude empty stock.
if (handFull) return fail(ERR.HAND_FULL);
this.spend(price);
slot.sold = true;
+4 -6
View File
@@ -33,15 +33,13 @@ export class PlayerPrep {
if (!this.gd.item(slot.id)) return fail(ERR.BAD_TARGET);
if (!this.m.itemPool.canGain(slot.id)) return fail(ERR.SOLD_OUT);
if (handFull) return fail(ERR.HAND_FULL);
} else {
} else if (slot.kind === 'chess') {
const rec = this.gd.chess(slot.id);
if (!rec || (rec.isDiy && !rec.diyFor)) return fail(ERR.BAD_TARGET);
const base = this.gd.baseIdOf(slot.id);
const need = rec.isGolden ? this.gd.goldenCopies : 1;
const pool = this.poolOf(base); // the shared pool, or this player's 自选 stock
if (pool.has(base) && pool.left(base) < need) return fail(ERR.SOLD_OUT);
// Owner decision 2026-10-11 (#498): already displayed operator rewards survive stock exhaustion.
// acquireChess records the deficit. Item rewards above still require itemPool.canGain.
if (handFull) return fail(ERR.HAND_FULL);
}
} else return fail(ERR.BAD_TARGET);
const price = Number.isFinite(slot.price) && slot.price > 0 ? Math.trunc(slot.price) : 0;
if (price > this.funds) return fail(ERR.NO_FUNDS);
slot.sold = true;
+2 -1
View File
@@ -6,7 +6,8 @@
// (config.economy.poolCopies[tier], measured identity overrides; co-op shared stock doubled). `left` is the
// signed capacity minus occupation; the public left() and shop sampling clamp a deficit to zero.
// * Owning a piece takes copies: a normal piece holds 1, an elite holds 3 (merge of 3 normals). Shop displays
// do NOT reserve copies; buying fails (SOLD_OUT) when left = 0.
// do NOT reserve copies. An item buy fails (SOLD_OUT) when left = 0; an operator card already shown in the shop or a
// reward can still be taken and overdraws (a negative raw left, the debt — GitHub #498, the owner's decision).
// * Pieces remember how many copies they hold (`piece.poolCopies`), so selling / elimination / temp wipes return
// exactly what was taken — chess granted by effects while the pool is empty (or hidden/banned chess) hold 0.
// * Invariant: raw left ≤ cap and raw left + Σ held copies == cap. Mimic can exceed stock, but its copies still occupy it.
+1 -1
View File
@@ -139,7 +139,7 @@ export function uniteBattleOpts(m, plan, timeLimit) {
if (!u.alive) { carry.set(u.uid, { down: true }); continue; }
// HP ratio and SP only: a skill still running at the end is not carried (unitsEnd `skillActive` stays unused —
// community report #34, GitHub #82)
carry.set(u.uid, { hpPct: Number.isFinite(u.hpPct) ? Math.max(0.01, Math.min(1, u.hpPct)) : 1, sp });
carry.set(u.uid, { hpPct: Number.isFinite(u.hpPct) ? Math.max(0, Math.min(1, u.hpPct)) : 1, sp });
}
// 2 helpers: the first one meets the enemies first on the right-hand field (escaped_multi enters at col 18)
const colOffset = plan.helpers.length > 1 && i === 0 ? 8 : 0;
+13 -2
View File
@@ -171,6 +171,16 @@ export function acquireTargets(b, u, prof) {
if (u.blocking.length) for (const e of b.blockedTargets(u, prof)) if (!cands.includes(e)) cands.push(e);
// an ally target (Battle.setAllyTarget: 白铁's 铁钳号·原型机, an enemy-camp summon with 嘲讽等级 −2) after every enemy
const extra = b._allyTargets && b._allyTargets.size ? b.allyTargetsInKeys(u.rangeKeys, u) : null;
// Fortress has an extra melee tile even against enemies it cannot block (PRTS branch / 号角). Its ordinary
// ranged targets still take priority; this does not fill the other tiles in its ranged blind spot.
if (prof.fortress && !cands.length && !extra?.length) {
const here = [u.tileR * COLS + u.tileC];
const near = b.enemiesInKeys(here, u, prof);
if (!near.length && b._allyTargets?.size) near.push(...b.allyTargetsInKeys(here, u));
prof._fortressMelee = near.length > 0;
sortEnemyTargets(b, u, near, prof.priority);
return near.slice(0, 1);
}
if (extra && extra.length) {
if (prof.allInRange) return cands.concat(extra);
sortEnemyTargets(b, u, cands, prof.priority);
@@ -225,7 +235,8 @@ export function performAttack(b, u, prof, targets, opts = null) {
const t = targets[i];
b._ev(['atk', u.id, t.id, vis]);
if (isHeal) { doHeal(b, u, prof, t); continue; }
const info = { isSkill, index: i, attackId, energy: i === 0 ? energy : 0 };
// Target acquisition mutates the fortress profile. Keep this shot's mode while its shell is in flight.
const info = { isSkill, index: i, attackId, fortressMelee: !!prof._fortressMelee, energy: i === 0 ? energy : 0 };
const foe = t.side === 'enemy' || b.isAllyTarget(t);
if (ranged && foe && prof.projectile === 'boomerang') {
throwBoomerang(b, u, prof, t, info);
@@ -323,7 +334,7 @@ export function resolveHit(b, u, prof, target, info, x, y) {
x = target.x; y = target.y;
}
// splash: a 中点判定 radius around the target's position — a huge enemy counts by its 判定中心 (Battle.enemiesInRadius)
if (prof.splashRadius > 0) {
if (prof.splashRadius > 0 && (!info.fortressMelee || prof.fortressMeleeSplash)) {
const r = prof.splashRadius;
const sc = prof.splashScale ?? 1;
for (const e of b.foesInRadius(x, y, r, true)) {
+8 -6
View File
@@ -46,13 +46,15 @@ export class BattleDeploy {
u.markDirty();
u.hp = u.s.maxHp;
// 联防 carry (PRTS 卫戍协议/帮助 §联防阶段 "将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力…)"):
// an operator's HP ratio here, the SP in skill.reset and again after the `deploy` hook (below) — a summon's SP only
// an operator restores HP/SP, a summon SP only; actual 联防 opening restoration is deferred until Battle.start ends
const cs = initial || (first && this._startDeploying) ? (carry ?? u.carry) : null;
if (cs && u.kind === 'op' && Number.isFinite(cs.hpPct)) u.hp = Math.max(1, u.s.maxHp * Math.max(0.01, Math.min(1, cs.hpPct)));
// 联防 reconstructs deployment/battle-start effects first, then restores HP/SP once in Battle.start.
const restoreLater = this.kind === 'unite' && this._startDeploying;
if (!restoreLater && cs && u.kind === 'op' && Number.isFinite(cs.hpPct) && cs.hpPct > 0) u.hp = u.s.maxHp * Math.min(1, cs.hpPct);
this._occ[k] = u;
this._refreshRange(u); // also rebuilds baseRangeKeys (initial range incl. permanent rangeExtend)
if (!u.skill) this._setupUnit(u);
u.skill.reset(cs);
u.skill.reset(restoreLater ? null : cs);
const sk = u.skill;
// "自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却" (PRTS 卫戍协议/帮助; skills.js)
if (initial) sk.opReadyAt = this.time + this.flags.startOpCooldown;
@@ -68,9 +70,9 @@ export class BattleDeploy {
if (first) this._ev(['spawn', unitInfo(u)]);
this._ev(['deploy', u.id]);
if (this._hooks.deploy) this.emit('deploy', { unit: u, initial });
// 联防: "部署完成后,将对应单位的…技力修改至与上一阶段结束时相同" — the carried SP is set again once the deployment is
// done, so a deploy-time SP gift (独行, 黄沙罗盘 …) does not come on top of it; later redeploys keep those gifts
if (cs && Number.isFinite(cs.sp) && u.alive && u.skill) u.skill.setSpTotal(cs.sp);
// Explicit carry outside the 联防 opening follows this one deployment. The opening itself restores at the end
// of Battle.start, so deployment/battle-start gifts do not come on top of it; later redeploys keep their gifts.
if (!restoreLater && cs && Number.isFinite(cs.sp) && u.alive && u.skill) u.skill.setSpTotal(cs.sp);
return true;
}
+9
View File
@@ -50,6 +50,15 @@ export class BattleLifecycle {
}
}
this.emit('battleStart', {});
// 联防 restores the previous phase's state after all opening effects have established the new stats. In
// particular, Egir rebuilds devour attributes without charging carried targets for its opening damage again.
// Keep fractional HP: a legal survivor below 1% (or below 1 HP after max-HP changes) must not be healed by carry.
if (this.kind === 'unite') for (const u of this.allyUnits) {
const cs = u.carry;
if (!cs || cs.down || !u.alive || !u.deployed || u.deploySeq <= seq0) continue;
if (u.kind === 'op' && Number.isFinite(cs.hpPct) && cs.hpPct > 0) u.hp = u.s.maxHp * Math.min(1, cs.hpPct);
if (Number.isFinite(cs.sp) && u.skill) u.skill.setSpTotal(cs.sp);
}
// redeploy-time effects that start with the battle (机变 征召 "所有干员的再部署时间-50%", added by a battleStart
// handler) cover the operators forced out above too: their timer is re-read with them, as a later knock-out's is
for (const u of this.allyUnits) {
+2 -2
View File
@@ -31,8 +31,8 @@
// 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 +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
// auto_chess_change_map Touch 外勤医疗 "所有玩家场地上出现一名<预备干员-医疗>": once selected in this match, its
// retained team grant gives every player's field the map character of the
// variant whose common_condition holds for that player's elites on the field
// (condition_golden_chess_le cnt → 预备干员-医疗, _ge cnt → Touch), spawned at
// combat start on its stage slot (tokens.spawnMapChar)
+5 -1
View File
@@ -457,7 +457,11 @@ function devour(battle, pid, bb, members) {
// start, resolves nothing (`knocked`: it is off the field)
if (knocked(t)) continue;
S.fxOn(battle, 'devour', t, 'bond:egirShip', 'devour', { from: m.id });
if (amount > 0) battle.loseHp(t, mitigate(amount, 'phys', t.s), { source: m, tags: ['bond:egir:devour'] });
// The maintainer's 2026-10-11 联防 carry decision: reconstruct marks/attributes, but do not apply a second
// opening HP loss (or trigger deaths/revives) to an operator restored from the previous field. Battle.start
// applies its exact HP ratio and SP after the opening hooks. Fresh units and all other battle kinds still pay.
const restored = battle.kind === 'unite' && Number.isFinite(t.carry?.hpPct) && t.carry.hpPct > 0;
if (amount > 0 && !restored) battle.loseHp(t, mitigate(amount, 'phys', t.s), { source: m, tags: ['bond:egir:devour'] });
if (!layered.has(t)) {
layered.add(t);
S.gainLayers(battle, { playerId: pid, bonds: 'egirShip', n: S.tierOf(t), source: m, reason: 'bond' });
+4 -2
View File
@@ -337,10 +337,11 @@ function rebirthCleanse(b, e) {
* (no move / attack / skill).
* `onKo(b, e, a)` runs at once (self-destruct, freeing prisoners …), `during(b, e, a, elapsed)` every tick of it; then the
* enemy stands up with `hpRatio` of its max HP, `onReborn(b, e, a)` switches the form and `invincible` s of 无敌 follow.
* `invincibleUntargetable` opts a verified enemy into hostile selection immunity for that same window (Xi, #510).
* `a.state`: undefined → 'reborn' → 'form2'. The 'telegraph' fx of the knock-out carries `form: 'reborn'` and the 'revive'
* fx `form: 'form2'`: models with those clip sets (render/units.js FORMS — 锏, 扎罗, “复仇者”, 杰斯顿) play them.
*/
function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = null, onReborn = null, key = 'ab:reborn' } = {}) {
function reborn({ dur = 0, hpRatio = 1, invincible = 0, invincibleUntargetable = false, onKo = null, during = null, onReborn = null, key = 'ab:reborn' } = {}) {
const finish = (b, e, a) => {
if (!e.alive || a.state !== 'reborn') return;
a.state = 'form2';
@@ -348,7 +349,8 @@ function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = nu
e.profile.noAttack = a.noAtk;
if (onReborn) safe(b, e, () => onReborn(b, e, a));
e.hp = Math.max(1, e.s.maxHp * Math.max(0, Math.min(1, hpRatio)));
if (invincible > 0) b.addBuff(e, { key: `${key}:inv`, duration: invincible, visible: true, flags: { invulnerable: true } });
if (invincible > 0) b.addBuff(e, { key: `${key}:inv`, duration: invincible, visible: true,
flags: { invulnerable: true, ...(invincibleUntargetable ? { untargetable: true } : {}) } });
if (e.route) e.route.pts = null;
e.atkCd = 0;
rebirthCooldowns(e);
+3
View File
@@ -473,6 +473,9 @@ function kitXi(ab) {
};
const re = reborn({
dur: T(ab, 'reborn.duration') ?? 0, invincible: T(ab, 'reborn.invincible') ?? 0,
// #510: Xi's post-rebirth 无敌 also prevents hostile selection (PRTS “自在” / 无敌).
// Explicit opt-in: the engine's general invulnerable flag remains damage protection only.
invincibleUntargetable: true,
onKo(b, e) { b.removeBuff(e, 'ab:xiShield'); },
onReborn(b, e) {
P.form2 = true;
+3 -1
View File
@@ -412,7 +412,9 @@ export const SPECIAL_KITS = Object.freeze({
// report of 2026-10-06, item 24); it picks by 仇恨值, not its blocker first
spawn(b, e) { e.profile.blockFree = true; },
dealt(c, b, e) {
const n = T(ab, 'Attack.attack@times') ?? 1, r = T(ab, 'Attack.attack@projectile_range') ?? 0;
// PRTS 自制投石机: normal stones splash in radius 1.5. The talent's projectile_range (0.5) belongs to
// the separate 入戏 random-offset attack, not its ordinary volley in this mode.
const n = T(ab, 'Attack.attack@times') ?? 1, r = 1.5;
const t = c.target;
for (let i = 1; i < n; i++) hurt(b, e, t, e.s.atk, 'phys');
// the stone's splash ("碰撞无视迷彩"): an area selection — no unblocking 隐匿 ally
+1 -1
View File
@@ -239,7 +239,7 @@ H.SERVER_GAIN_EQUIP = {
run(ctx) {
const { bb, bbStr } = ctx.source;
if (!ctx.gd.item(bbStr.chess)) return;
for (let i = 0; i < Math.min(10, num(bb.count, 1)); i++) ctx.grantItem(bbStr.chess);
for (let i = 0; i < Math.min(10, num(bb.count, 1)); i++) ctx.grantItem(bbStr.chess, { requirePool: false });
},
};
+15 -5
View File
@@ -33,7 +33,7 @@
// 0.2.0 both revived in place (`fatal` savers at −101 / −110): no 被击倒时 / 部署时 effect fired (community report
// 「像砾和瑕光这种死亡和部署的叠层效果,如果有艾芒加德的3次复活似乎是无法触发」).
// 骑士戒律's free in-skill undying runs early (20). Flat damage reduction 'hit' −10 (after the other damage modifiers).
// Proc damage dealt by items carries the tag 'item' and never re-triggers item procs.
// Proc damage dealt by items carries the tag 'item'; damage-producing item riders exclude it to avoid recursion.
//
// 蒸汽之心 (Victoria carrier) — LIVE model: the hammer types (灼燃/坚固/加速/战栗维式重锤) carried by the owner's operators
// that are on the field right now (cached ≤ 0.5 s, dropped whenever an operator deploys or leaves) are granted to the
@@ -389,6 +389,15 @@ function onAttackEnemies(S, u, fn) {
});
}
/** Actual carrier damage to enemies, including DoT/skill/splash. Element gauge fill and HP loss are not damage. */
function onDamageEnemies(S, u, fn) {
S.on('damaged', (c) => {
if (c.source !== u || c.type === 'element' || isHpLoss(c.dmg) || !(c.amount > 0)
|| !c.target || c.target.side !== 'enemy' || !hasHp(c.target)) return;
fn(c.target, c);
});
}
/** Generic buff-key behaviours (shared by several items). */
const BY_BUFF = {
// 源石溶剂: the text's "每秒流失 damage 点生命值" is officially 每秒受到 damage 点真实伤害 (PRTS 盟约记录 修正 "并非流失", 备注
@@ -636,18 +645,19 @@ const BY_ITEM = {
fxOn(battle, 'revive', u, 'item:chess_item_4_12_e', rec.id, { left: max - used });
}, PRIO_RESPAWN); // a knock-out: after every 不死 (the hammer's lock), whatever the equip order (header)
},
// 催泪瓦斯: on attack prob ⇒ 1 麻痹 stack
// PRTS 盟约记录 corrects both items' "攻击时" to "造成伤害时" (#489); no attack event is required.
// 催泪瓦斯: on damage prob ⇒ 1 麻痹 stack
chess_item_5_01_e(battle, u, rec, S) {
const p = bp(rec, 'act1autochess_equip_acarm058_global_buff');
const pr = p ? num(p.prob) : 0;
if (pr > 0) onAttackEnemies(S, u, (t) => { if (chance(battle, pr)) battle.applyStatus(t, 'palsy', { value: 1, source: u }); });
if (pr > 0) onDamageEnemies(S, u, (t) => { if (chance(battle, pr)) battle.applyStatus(t, 'palsy', { value: 1, source: u }); });
},
// 谢拉格不融冰: on attack prob ⇒ 寒冷 `cold` s
// 谢拉格不融冰: on damage prob ⇒ 寒冷 `cold` s
chess_item_5_02_e(battle, u, rec, S) {
const p = bp(rec, 'act1vautochess_equip_acarm003_global_buff');
if (!p) return;
const pr = num(p.prob), d = num(p.cold);
if (pr > 0 && d > 0) onAttackEnemies(S, u, (t) => { if (chance(battle, pr)) battle.applyStatus(t, 'cold', { duration: d, source: u }); });
if (pr > 0 && d > 0) onDamageEnemies(S, u, (t) => { if (chance(battle, pr)) battle.applyStatus(t, 'cold', { duration: d, source: u }); });
},
// 双模机械臂: physical/arts damage becomes 弱点伤害 (whichever type the target resists less)
chess_item_5_03_e(battle, u, rec, S) {
@@ -94,8 +94,9 @@ export default {
battle.on('damaged', (ctx) => {
const e = ctx.target;
if (busy || ctx.source !== unit || e.side !== 'enemy' || ctx.type === 'element' || ctx.dmg?.tags?.includes('vulpisHunt')) return;
const t = marks.get(e.id);
if (t == null) { marks.set(e.id, battle.time); return; }
let t = marks.get(e.id);
// PRTS talent remarks: the first damage instance also receives the arts addition.
if (t == null) { t = battle.time; marks.set(e.id, t); }
if (battle.time - t <= num(t0.interval, 10) + 1e-9 && e.alive) {
busy = true;
// PRTS 备注 "伤害类型为法术附加伤害": tag addition (no 叙拉古 6 roll)
@@ -50,7 +50,7 @@ export default {
}),
skchr_horn_2: () => ({
kind: 'ammo', ammo: s2Ammo,
attack: { atkScale: num(bb['attack@s2.atk_scale'], 1) },
attack: { atkScale: num(bb['attack@s2.atk_scale'], 1), fortressMeleeSplash: true },
onStart({ unit }) { unit.mem.hornS2Over = false; },
onEnd({ unit }) { unit.mem.hornS2Over = false; },
}),
+2
View File
@@ -229,6 +229,8 @@ export default {
},
[S3]: {
kind: 'ammo',
// The skill's initial attack area includes the eight surrounding tiles as well as its 3-9 grid.
trigger: { rule: 'ACTIVE_RANGE', grid: g39x4 },
ammo: Math.max(1, Math.floor(num(b3['attack@trigger_time'], 1))),
mods: { atkPct: num(b3.atk) }, flags: LIFTOFF,
targeting: s3Targeting,
+1 -1
View File
@@ -222,7 +222,7 @@ export default {
}]);
}
battle.on('elementBurst', ({ target, element }) => {
if (element !== 'erosion' || !up(unit) || !unit.skill?.active || unit.skill.id !== S3
if (element !== 'erosion' || target?.side !== 'enemy' || !up(unit) || !unit.skill?.active || unit.skill.id !== S3
|| !canTargetEnemy(unit, target, GROUND)) return;
unit.mem.clemntBombs?.marks.set(target, battle.time + num(b3.ep_break_duration, 1));
}, { owner: unit });
+6 -1
View File
@@ -53,7 +53,7 @@
// 帮助 lists the 上半 row that did as removed in 下半, so in a fight the skill simply runs its `duration`.
import { num, talentBb, traitBb, skillRec, up, byEnemyAttack } from '../shared/tier1.js';
import { canTargetEnemy } from '../../../targeting.js';
import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
import { bodyInRadius } from '../../../body.js';
import { hasHp } from '../../../damage.js';
import { COLS } from '../../../constants.js';
@@ -256,6 +256,11 @@ export default {
} },
],
install(battle, unit) {
// S3 attacks air as well: its expanded auto-cast area must not inherit the inactive ground-only profile.
if (unit.skill?.id === S3) unit.skill.addTriggerRange(() => [{
keys: absoluteRangeKeys(skillRec(chess, S3)?.rangeGrid ?? X2, unit.tileR, unit.tileC, unit.dir, unit.s.baseRangeExtend || 0),
profile: { canHitFly: true },
}]);
// S1 恶业苦果: every enemy damage instance she takes (the official hsgma2_s_1 on ON_TAKE_DAMAGE, not its attacks only:
// tier1 byEnemyAttack) ⇒ atk_scale × ATK arts back to its source
battle.on('damaged', (c) => {
+11
View File
@@ -294,6 +294,17 @@ export default {
],
install(battle, unit) {
const id = unit.skill?.id;
if (id === S2) {
// The owner's larger-range auto-cast rule also covers this terrain-dependent range (no static rangeId).
// Keep SEARCH when the actual running area does not strictly contain her initial range.
unit.skill.addTriggerRange(() => {
const extend = unit.s.baseRangeExtend || 0;
const keys = absoluteRangeKeys(floodGrid(battle, unit), unit.tileR, unit.tileC, unit.dir, extend);
const initial = absoluteRangeKeys(unit.def.rangeGrid, unit.tileR, unit.tileC, unit.dir, extend);
const set = new Set(keys);
return set.size > initial.length && initial.every(k => set.has(k)) ? [{ keys, profile: AIR }] : [];
});
}
if (id === S1) {
// 充能耗尽前特性不重置: the trait resets the ramp on skillEnd (priority 0) — keep it while a charge remains
battle.on('skillEnd', (c) => {
+1 -1
View File
@@ -129,7 +129,7 @@ export function contentInfo(battle, pid) {
const ci = player(battle, pid)?.input?.contentInfo;
return ci && typeof ci === 'object' ? ci : {};
}
/** Band ids of every alive player of the match (own first); falls back to this battle's players. */
/** Alive players' bands (own first), plus a retained Touch team grant; falls back to this battle's players. */
export function matchBands(battle, pid) {
const mb = contentInfo(battle, pid).matchBands;
if (Array.isArray(mb) && mb.length) return mb;
+6 -2
View File
@@ -6,7 +6,7 @@
// handUnits pieces in the 整备区 (hand, 10 slots; operators, items and summon stacks),
// handChess operators in the hand,
// roundStats { refreshes, buys, sells, spent, gainedChess, arts } (this round's prep, ps.round),
// matchBands band ids of every alive player (this player first),
// matchBands band ids of every alive player (this player first), plus a retained Touch team grant,
// teammates [{ playerId, bandId }] of the other alive players }
// Global handlers run first in every dispatch, so band / bond / garrison / item handlers of the same onBattleStart can
// read or extend `ev.input.contentInfo`.
@@ -26,6 +26,10 @@ export function registerSupportMeta(registry) {
const hand = ctx.hand();
const rs = ctx.roundStats();
const mates = ctx.teammates();
const matchBands = [ctx.bandId(), ...mates.map(t => t.bandId())].filter(Boolean);
// [ASSUMED] 外勤医疗 is a team grant for this match, not an aura revoked when its holder is eliminated.
// Other team strategies and the teammate contexts still require a living player.
if (ctx.teamMedicGranted?.() && !matchBands.includes('band_amedic')) matchBands.push('band_amedic');
ev.input.contentInfo = {
round: ctx.round,
kind: ev.kind ?? null,
@@ -37,7 +41,7 @@ export function registerSupportMeta(registry) {
refreshes: rs.refreshes | 0, buys: rs.buys | 0, sells: rs.sells | 0, spent: rs.spent | 0,
gainedChess: rs.gainedChess | 0, arts: rs.arts | 0,
},
matchBands: [ctx.bandId(), ...mates.map((t) => t.bandId())].filter(Boolean),
matchBands,
teammates: mates.map((t) => ({ playerId: t.playerId, bandId: t.bandId() })),
};
},
+5
View File
@@ -493,6 +493,11 @@ export class SkillRuntime {
|| (this.triggerEnemies && b.enemiesInKeys(keys, u, TRIGGER_PROFILE).length > 0);
if (b.enemiesInKeys(keys, u, u.profile).length > 0 || this._allyTargetIn(keys)) return true;
}
if (!this.healSkill && u.profile?.fortress) {
// The branch's own-tile melee range also satisfies the basic cast condition for unblockable fragments.
const here = [u.tileR * COLS + u.tileC];
if (b.enemiesInKeys(here, u, u.profile).length > 0 || this._allyTargetIn(here)) return true;
}
// the enemies a unit blocks are always its targets (Battle.blockedTargets), in range or not — PRTS 卫戍协议/帮助
// "敌人被近战干员自身阻挡" satisfies the target condition of the basic strategy (a ranged blocker too: user playtest #6)
if (!this.healSkill && u.blocking.length && b.blockedTargets(u, u.profile).length > 0) return true;