Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash

- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays
  under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed
- #2 two-tap confirm on 机变 cards (like the shop)
- #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles)
- #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band
- #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from
- #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others)
- #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats)
- #8 official element gauges on units; burst cooldown (阻回) per PRTS
- #9 knocked-down operators stay on their tile with a redeploy countdown ring
- #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼)
- #11 赫默's drone only from her skill; placeable summons limited to talent tokens
- #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel
- #13 the occasional giant bond icon (loading texture scaled ×46) fixed
- docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
This commit is contained in:
sganggs committed 2026-09-30 05:35:45 +08:00
1 parent 0aba602ecf
commit d4f868cd19
107 files changed
+4510 -1653

No files matched your search

+154 -27
View File
@@ -75,6 +75,10 @@
// Live LP (user playtest #3 item 2): during COMBAT / 联防 m.public players[].pendingLp = min(lpCapPerRound, the counted
// leaks of the player's own battle so far) (_pendingLpView; omitted when 0) — the teammates' rows of the team panel
// show lp − pendingLp; the settlement lands with the SETTLE view, where it is gone.
// User playtest #4: a match with a single human (独立模拟, or a 同盟 room started alone / with AI teammates) times no
// phase outside its battles (soloUntimed); the co-op strategy draft has ONE countdown — BAND_TURN_SECONDS per turn,
// published as m.public.deadline — AI seats pick at once and a turn that runs out takes the highlighted strategy
// (g.bandFocus → timeoutBand); g.unitStats answers m.unitStats: the stats the board's units start their next battle with.
// opts.clientCombat default true (env SP_COMBAT=server → false: the legacy server-run + snapshot streaming mode)
// opts.verify 'off' | 'sample' | 'all' (env SP_VERIFY, default 'off'): re-simulate accepted client results
// ('sample': ~1 in 8, in a later callback, mismatches logged; 'all': before accepting — the
@@ -108,7 +112,7 @@
// pairing and the boss pool; its own running normal battle is force-ended. The seat shows status 'left'. When no
// human is left at all the match ends ('abandoned'); when nobody alive is left it ends as 'eliminated'.
import { C2S } from '../../shared/protocol.js';
import { C2S, unitStatsEntry } from '../../shared/protocol.js';
import { PHASE, ERR, EMOTES, EMOTE_COOLDOWN_MS, GEO, modeIdFor } from '../../shared/constants.js';
import { Battle } from '../sim/Battle.js';
import { DataSource } from '../sim/simdata.js';
@@ -182,6 +186,14 @@ export const DELAYS = Object.freeze({
PUBLIC_THROTTLE: 100,
});
/**
* Seconds of one turn of the co-op strategy draft (user playtest #4 item 4: the old 12 s per turn — research 06 §724,
* itself [ASSUMED] — inside the 50 s step was far too little and counted apart from the header's 50 s). [ASSUMED]: the
* official data only gives the whole BAND_CHECK step (autoChessData.enterStepList: 50 s, hint 15 s); the turn clock is
* the remake's. It is also the step's only countdown (m.public.deadline = draft.turnDeadline). × timerScale.
*/
export const BAND_TURN_SECONDS = 30;
const dsCache = new WeakMap();
function dataSourceFor(data) {
if (!data || typeof data !== 'object' || !data.chess) return undefined;
@@ -270,6 +282,12 @@ export class Match {
}
if (!this.players.size) throw new TypeError('Match: seats required');
this.order = [...this.players.values()].sort((a, b) => a.seat - b.seat);
/**
* Exactly one human seat at the start (独立模拟, or a 同盟 room started alone / with AI teammates only): nobody waits
* on anybody, so no phase outside a battle is timed — soloUntimed (user playtest #4 item 3). The mode's own rules
* (draft order and skip, 6 机变 cards, 联防 …) stay.
*/
this.loneHuman = this.order.filter((p) => !p.isBot).length === 1;
// per-match setup (DESIGN §6.5)
const setup = setupMatchWaves(this.gd, this.rngSetup);
@@ -559,9 +577,11 @@ export class Match {
* Solo timers (research 01 §843 / 06 §3: 下半 独立模拟 has no time limit on 休整期 / 机变 — "休整期及机变阶段没有时间
* 限制"; the strategy draft is free too): a solo match publishes a deadline ONLY for its battles. INFO_CHECK waits
* for 准备就绪 (co-op keeps the official 25 s guard), BAND_DRAFT / SP_DRAFT / PREP are untimed, and the fixed
* presentation steps (BATTLE_CHECK, ROUND_START, SETTLE) run silently (no countdown).
* presentation steps (BATTLE_CHECK, ROUND_START, SETTLE) run silently (no countdown). The same holds for any match
* with a single human (loneHuman: a 同盟 room started alone or with AI teammates only — user playtest #4 item 3):
* the timers only ever made humans wait on each other; AI seats act at once.
*/
get soloUntimed() { return this.isSolo; }
get soloUntimed() { return this.isSolo || this.loneHuman; }
nextUid() { return ++this.uidSeq; }
@@ -772,7 +792,11 @@ export class Match {
if (this.bossPool) v.bossHp = { hp: Math.max(0, Math.round(this.bossPool.hp)), max: Math.round(this.bossPool.maxHp) };
if (this.phase === PHASE.BAND_DRAFT && this.draft) {
const d = this.draft;
v.draft = { order: d.order.slice(), turn: this.draftTurn(), picks: { ...d.picks }, skipsLeft: { ...d.skipsLeft }, turnDeadline: d.turnDeadline || 0, untimed: !!d.untimed };
// turnSeconds: the length of a turn (the countdown gauge's total; 0 when untimed) — deadline = turnDeadline
v.draft = {
order: d.order.slice(), turn: this.draftTurn(), picks: { ...d.picks }, skipsLeft: { ...d.skipsLeft }, turnDeadline: d.turnDeadline || 0,
turnSeconds: d.untimed ? 0 : this.bandTurnMs() / 1000, untimed: !!d.untimed,
};
}
if (this.phase === PHASE.SP_DRAFT && this.sp) {
const s = this.sp;
@@ -856,6 +880,8 @@ export class Match {
return OK;
case 'g.band': return this.pickBand(ps, msg.bandId);
case 'g.bandSkip': return this.skipBand(ps);
// the strategy highlighted in the draft screen (what a timed-out turn takes, timeoutBand)
case 'g.bandFocus': return this.bandFocus(ps, msg.bandId ?? null);
case 'g.buy': return ps.buy(msg.slot);
case 'g.refresh': return ps.refresh();
case 'g.freeze': return ps.freeze();
@@ -873,6 +899,8 @@ export class Match {
case 'g.watch': return this.watch(ps, msg.fieldId);
case 'g.autoplay': return this.setAutoplay(ps, !!msg.on);
case 'g.pause': return this.setPause(ps, !!msg.on);
// the stats the board's units start their next battle with (the detail card in prep, user playtest #4 item 7)
case 'g.unitStats': return this.unitStats(ps, msg.seq ?? null);
case 'g.leave': this.onLeave(ps.playerId); return OK;
case 'b.progress': return this._onProgress(ps, msg);
case 'b.result': return this._onResult(ps, msg);
@@ -924,6 +952,55 @@ export class Match {
return OK;
}
/**
* g.unitStats { seq? } (user playtest #4 item 7: the detail card showed fixed record stats): the stats every unit of
* the player's board will fight with at the start of its next battle — equipment, bonds and their layers, 特质, the
* band and 机变 effects — computed exactly by the shared sim. The player's battle input after the onBattleStart meta
* handlers (`ev.preview: true`, no enemies — those handlers must not change the match for a preview) builds a Battle
* of the battle's options that is started (initial deployment + battleStart hooks), read and dropped: it is never
* stepped, so skills and timed effects do not show. Pushed to the player as `m.unitStats { seq, round, units }`
* (units: shared/protocol.js unitStatsEntry, board operators and summons by uid); cached per input (a build costs
* ≈ 0.3–0.7 ms). Prep phases only (ROUND_START, 机变, PREP); a battle's live stats come from the browser's own sim.
* @param {PlayerState} ps @param {number|null} seq echoed (the client keeps the newest answer)
*/
unitStats(ps, seq = null) {
if (!ps.alive) return fail(ERR.ELIMINATED);
if (this.phase !== PHASE.ROUND_START && this.phase !== PHASE.SP_DRAFT && this.phase !== PHASE.PREP) return fail(ERR.WRONG_PHASE);
const units = this._unitStatsOf(ps);
this.sendTo(ps.playerId, { t: 'm.unitStats', seq: Number.isInteger(seq) ? seq : null, round: this.round, units });
return OK;
}
/** The start-of-battle stats of a player's board units (see unitStats); [] when the preview battle cannot be built. */
_unitStatsOf(ps) {
const input = ps.battleInput({ side: 'L', colOffset: 0 });
const ev = { input, kind: 'normal', round: this.round, preview: true };
this.dispatch(ps, 'onBattleStart', ev);
const players = [ev.input && typeof ev.input === 'object' ? ev.input : input];
let key = null;
try { key = JSON.stringify([this.round, this.stageId, this.battleContent, players]); } catch { key = null; }
if (!this._unitStatsCache) this._unitStatsCache = new WeakMap(); // PlayerState → { key, units } (the last preview)
const cached = this._unitStatsCache.get(ps);
if (key && cached && cached.key === key) return cached.units;
const units = [];
// the flags of the battle it previews: a normal round gains IN_BATTLE layers from its start (a <战斗开始时> layer gain
// raises bond stats at t = 0 there too); the Final Assault / Hidden Core fight without (previewed as a normal field)
const bossRound = this.round === this.gd.bossRound || this.round === this.gd.hiddenRound;
const b = this.newBattle({
seed: deriveSeed(this.seed, `preview:${this.round}:${ps.seat}`), kind: 'normal', modeId: this.modeId, round: this.round,
stageId: this.stageId, rect: { ...GEO.NORMAL_RECT }, timeLimit: 60, players, spawns: [], routes: this.wave ? this.wave.routes : [],
sharedBoss: null, flags: { layerGainsEnabled: !bossRound, ...this.gd.dp }, fieldId: `n:${ps.playerId}`, recordEvents: false,
});
try {
if (typeof b.start === 'function') b.start();
for (const u of Array.isArray(b.allyUnits) ? b.allyUnits : []) {
if (u && Number.isInteger(u.uid) && (u.kind === 'op' || u.kind === 'token')) units.push(unitStatsEntry(u, u.s));
}
} catch (e) { this.reportError('unitStats', e); }
this._unitStatsCache.set(ps, { key, units });
return units;
}
/**
* Solo pause (official PauseUp / ResumeUp; DESIGN §14 "Solo pause"): g.pause { on } freezes the running battle — the
* field clock (b.start `elapsed`, the boss budgets and overtime), the result deadline / server release timers, the
@@ -1022,20 +1099,26 @@ export class Match {
// ===================================================================================================
// BAND_DRAFT
/**
* The strategy draft (user playtest #4 item 4): ONE countdown — every turn has the same clock, BAND_TURN_SECONDS, and
* m.public.deadline is the current turn's end (= draft.turnDeadline; the step header and the turn indicator show the
* same number). No separate step cap: the turns bound the step (≤ (seats + skips) × turn). AI seats pick at once. A
* turn that runs out takes the strategy the player has highlighted (g.bandFocus) while it is free, else the default
* (timeoutBand). Solo, and any single-human match (soloUntimed): untimed. Solo also keeps seat order and has no skip.
*/
enterBandDraft() {
if (this.phase !== PHASE.INFO_CHECK) return;
this.phase = PHASE.BAND_DRAFT;
const order = this.order.map((p) => p.playerId);
const untimed = this.isSolo;
if (!untimed) this.rngDraft.shuffle(order);
const skips = this.gd.bandDraft.skipsPerPlayer;
this.draft = { order, idx: 0, picks: {}, skipsLeft: Object.fromEntries(order.map((pid) => [pid, untimed ? 0 : skips])), untimed, turnDeadline: 0 };
if (!untimed) {
// overall step cap (50 s): everyone still unpicked gets the default
this.setDeadline(this.gd.timer('bandDraft'), () => this.finishBandDraft(true));
} else {
this.setDeadline(0);
}
if (!this.isSolo) this.rngDraft.shuffle(order);
const skips = this.isSolo ? 0 : this.gd.bandDraft.skipsPerPlayer;
const untimed = this.soloUntimed;
this.draft = {
order, idx: 0, picks: {}, skipsLeft: Object.fromEntries(order.map((pid) => [pid, skips])), untimed, turnDeadline: 0,
/** playerId → the strategy highlighted in the draft screen (g.bandFocus) */
focus: new Map(),
};
this.setDeadline(0);
this.startDraftTurn();
this.markPublic();
}
@@ -1046,30 +1129,44 @@ export class Match {
return d.order[d.idx] ?? null;
}
/** Real ms of one strategy-draft turn (BAND_TURN_SECONDS × timerScale). */
bandTurnMs() { return this.scaled(BAND_TURN_SECONDS * 1000); }
startDraftTurn() {
const d = this.draft;
this.cancel(this._turnTimer);
this._turnTimer = null;
while (d.idx < d.order.length && d.picks[d.order[d.idx]]) d.idx++;
if (d.idx >= d.order.length) { this.later(0, () => this.finishBandDraft(false)); return; }
if (d.idx >= d.order.length) {
d.turnDeadline = 0;
this.deadline = 0;
this.later(0, () => this.finishBandDraft(false));
return;
}
const token = ++this._turnToken;
if (!d.untimed) {
const ms = this.scaled(this.gd.timer('bandTurn') * 1000);
const ms = this.bandTurnMs();
d.turnDeadline = this.sched.now() + ms;
// the step's countdown IS the turn's (one number everywhere)
this.deadline = d.turnDeadline;
this._turnTimer = this.later(ms, () => {
if (this.phase !== PHASE.BAND_DRAFT || token !== this._turnToken) return;
const pid = this.draftTurn();
if (pid) this._applyBand(this.players.get(pid), this.defaultBand(pid));
if (pid) this._applyBand(this.players.get(pid), this.timeoutBand(pid));
});
} else {
d.turnDeadline = 0;
this.deadline = 0;
}
const cur = this.players.get(this.draftTurn());
if (cur && cur.botControlled) this.scheduleBandBot();
this.markPublic();
}
/** An AI seat's (or an AI 托管 seat's) turn: it picks at once (user playtest #4 item 4 — nobody waits on the AI). */
scheduleBandBot() {
const token = this._turnToken;
this.later(this.scaled(DELAYS.BOT_ACTION), () => {
this.later(0, () => {
if (this.phase !== PHASE.BAND_DRAFT || token !== this._turnToken) return;
const ps = this.players.get(this.draftTurn());
if (!ps || !ps.botControlled) return;
@@ -1084,7 +1181,7 @@ export class Match {
/**
* Whether `bandId` was already picked by another player of this draft. Research 09 §5 / DESIGN §14 corrections:
* the strategy draft marks a teammate's pick as 队友已选 and it cannot be chosen again (co-op). The automatic
* assignments — the 12 s turn timeout, the 50 s step cap and a departing seat — obey the same rule: see defaultBand.
* assignments — a turn that runs out and a departing seat — obey the same rule: see timeoutBand / defaultBand.
*/
bandTaken(bandId, playerId) {
const picks = this.draft?.picks || {};
@@ -1093,10 +1190,11 @@ export class Match {
}
/**
* The strategy an automatic assignment gives `playerId` (12 s turn timeout, 50 s step cap, departing seat): the official
* default 「华法琳」 (bandDraft.timeoutBandId) while no teammate holds it, else the first strategy of the mode (sortId
* order, gd.bandIds) that nobody else picked — never a duplicate (队友已选; the client shows the same choice:
* public/js/screens/bandDraft.js timeoutBand). Solo drafts have no teammates, so it is always the default.
* The strategy an automatic assignment gives `playerId` (a departing seat; a timed-out turn without a usable
* highlight, timeoutBand): the official default 「华法琳」 (bandDraft.timeoutBandId) while no teammate holds it, else the
* first strategy of the mode (sortId order, gd.bandIds) that nobody else picked — never a duplicate (队友已选; the
* client shows the same choice: public/js/screens/bandDraft.js timeoutBand). Solo drafts have no teammates, so it is
* always the default.
* @param {string} playerId
*/
defaultBand(playerId) {
@@ -1105,6 +1203,33 @@ export class Match {
return this.gd.bandIds().find((b) => !this.bandTaken(b, playerId)) || def;
}
/**
* What a turn that runs out assigns (user playtest #4 item 4): the strategy the player has highlighted in the draft
* screen (g.bandFocus — the detail pane's band, the one 确认选择 would take) while it is allowed and no teammate holds
* it, else defaultBand.
* @param {string} playerId
*/
timeoutBand(playerId) {
const f = this.draft && this.draft.focus instanceof Map ? this.draft.focus.get(playerId) : null;
if (typeof f === 'string' && this.gd.bandAllowed(f) && !this.bandTaken(f, playerId)) return f;
return this.defaultBand(playerId);
}
/**
* g.bandFocus { bandId? }: the strategy the player highlights in the draft screen (any time before its pick; also
* while waiting for its turn). A missing / null bandId clears it. Only a timed-out turn reads it (timeoutBand).
*/
bandFocus(ps, bandId) {
if (this.phase !== PHASE.BAND_DRAFT || !this.draft) return fail(ERR.WRONG_PHASE);
const d = this.draft;
if (d.picks[ps.playerId]) return fail(ERR.ALREADY);
if (!(d.focus instanceof Map)) d.focus = new Map();
if (bandId == null) { d.focus.delete(ps.playerId); return OK; }
if (typeof bandId !== 'string' || !this.gd.bandAllowed(bandId)) return fail(ERR.BAD_TARGET);
d.focus.set(ps.playerId, bandId);
return OK;
}
pickBand(ps, bandId) {
if (this.phase !== PHASE.BAND_DRAFT || !this.draft) return fail(ERR.WRONG_PHASE);
if (this.draft.picks[ps.playerId]) return fail(ERR.ALREADY);
@@ -1131,7 +1256,7 @@ export class Match {
skipBand(ps) {
if (this.phase !== PHASE.BAND_DRAFT || !this.draft) return fail(ERR.WRONG_PHASE);
const d = this.draft;
if (d.untimed) return fail(ERR.WRONG_PHASE, 'no skip in solo');
if (this.isSolo) return fail(ERR.WRONG_PHASE, 'no skip in solo');
if (d.picks[ps.playerId]) return fail(ERR.ALREADY);
if (this.draftTurn() !== ps.playerId) return fail(ERR.NOT_YOUR_TURN);
if (!(d.skipsLeft[ps.playerId] > 0)) return fail(ERR.ALREADY, 'no skip left');
@@ -1218,8 +1343,9 @@ export class Match {
if (!draft || !alive.length) { this.enterPrep(); return; }
this.phase = PHASE.SP_DRAFT;
const order = alive.map((p) => p.playerId);
const untimed = this.isSolo;
if (!untimed) this.rngDraft.shuffle(order);
if (!this.isSolo) this.rngDraft.shuffle(order);
// untimed: solo and any single-human match (soloUntimed); the co-op order / 6 cards stay
const untimed = this.soloUntimed;
this.sp = { ...draft, order, idx: 0, picks: {}, taken: {}, untimed, turnDeadline: 0 };
this.setDeadline(0);
this.startSpTurn();
@@ -1404,7 +1530,8 @@ export class Match {
this.dispatch(ps, 'onPrepStart', { round: this.round });
ps.recompute();
}
const secs = this.isSolo ? null : this.gd.prepTime(this.round);
// solo / single-human matches: untimed (soloUntimed); co-op: the round's prepTime
const secs = this.soloUntimed ? null : this.gd.prepTime(this.round);
this.setDeadline(secs, () => this.prepDeadline());
let i = 0;
for (const ps of alive) if (ps.botControlled) this.scheduleBotPrep(ps, i++);
+7 -12
View File
@@ -28,8 +28,9 @@
// 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
// (destroy for 0). consume-on-equip items resolve through the effect registry and never take a slot.
// * Tokens: placing an owner with placeable summons (tokens.json displayType DEFAULT) sends one stack
// (deployLimit copies) to the hand; withdrawing/selling/merging the owner removes its tokens.
// * Tokens: placing an owner with placeable summons (tokens.json `placeable`: the talent summons 海嗣 / 狼群 / 流形)
// sends one stack (deployLimit copies) to the hand; withdrawing/selling/merging the owner removes its tokens. A
// skill's summon (赫默 医疗无人机, 巫恋 诅咒娃娃) is never a hand card: it appears in battle when the skill fires.
// * Facing (DESIGN §3, research 09 §1.2): every board piece has `dir` ∈ UP|RIGHT|DOWN|LEFT (server/sim/dir.js), set
// by g.move {…, dir} (absent ⇒ RIGHT) and kept across rounds. g.move onto the piece's OWN tile re-orients it in
// place; a swap keeps the occupant's dir; pieces put on the board by effects (a merge elite taking a consumed
@@ -39,8 +40,7 @@
// equal to the defaults dropped) is re-checked against this match's data (shared/protocol.js checkLoadout; a
// mismatch falls back to the defaults) and kept frozen; bots always use the defaults. Match.setLoadout may replace
// 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`. A placeable summon of a
// skill (赫默 S2 医疗无人机) is granted only with that skill equipped (grantTokensFor).
// normal chess → moduleId null, elite → uniEquipId | 'none'); m.private exposes `loadout`.
import { ERR, GEO, PHASE } from '../../shared/constants.js';
import { checkLoadout, resolveLoadout } from '../../shared/protocol.js';
@@ -345,18 +345,13 @@ export class PlayerState {
}
/**
* Owner placed on the board: send its placeable summons to the hand (one stack per token type). A summon that comes
* from a skill (SkillRecord.overrideTokenKey, e.g. 赫默 S2 医疗无人机 "获得一个医疗无人机") is granted only while that
* skill is the one equipped under the player's loadout (DESIGN §16) — 赫默 on S1 has no drone.
* Owner placed on the board: send its placeable summons to the hand (one stack per token type). Only talent summons
* are placeable (gamedata.placeableTokens / tokens.json `placeable`); a summon a skill makes ("获得一个医疗无人机",
* 赫默 S2) appears in battle when that skill fires (user playtest #4: not on the field from the start).
*/
grantTokensFor(owner) {
const rec = this.gd.chess(owner.id);
const skills = rec && Array.isArray(rec.skills) ? rec.skills.filter(Boolean) : [];
const sel = skills.length ? this.loadoutFor(rec).skillIndex : null;
for (const { tokenId, count } of this.gd.placeableTokens(owner.id)) {
if (this._hasTokensOf(owner.uid, tokenId)) continue;
const bySkill = skills.filter((s) => s.overrideTokenKey === tokenId);
if (bySkill.length && !bySkill.some((s) => s.index === sel)) continue;
const t = this.newPiece('token', tokenId, { count, ownerUid: owner.uid });
this.stow(t, { allowTemp: true });
}
+15 -6
View File
@@ -29,14 +29,18 @@
// 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
// deadlines every timed phase's m.public deadline equals its configured duration × timerScale (a solo match
// runs BATTLE_CHECK / ROUND_START / SETTLE silently: deadline 0, Match.soloUntimed)
// deadlines every timed phase's m.public deadline equals its configured duration × timerScale; the co-op
// strategy draft has one countdown: the deadline is the current turn's (Match.BAND_TURN_SECONDS). A match
// with a single human (solo, or a 同盟 room with AI teammates only: Match.soloUntimed) times nothing
// outside its battles — no INFO_CHECK / draft / 机变 / prep deadline, BATTLE_CHECK / ROUND_START / SETTLE
// silent (deadline 0)
// Checks never throw into the match: an exception inside a check is itself recorded as a violation.
import { PHASE } from '../../shared/constants.js';
import { collectViolations } from './invariants.js';
import { pairPlayers, bossPoolHp, hiddenEligible } from './finalAssault.js';
import { helperOrder } from './unite.js';
import { BAND_TURN_SECONDS } from './Match.js';
/**
* @param {import('./Match.js').Match} m
@@ -236,7 +240,8 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
wrap(m, 'enterInfoCheck', function (orig) {
const r = orig();
runInvariants();
if (m.phase === PHASE.INFO_CHECK && m.deadline) expectDeadline(gd.timer('infoCheck'), 'INFO_CHECK');
if (m.phase === PHASE.INFO_CHECK && m.soloUntimed) check('briefing', () => { if (m.deadline) fail('untimed briefing is timed'); });
else if (m.phase === PHASE.INFO_CHECK && m.deadline) expectDeadline(gd.timer('infoCheck'), 'INFO_CHECK');
return r;
});
wrap(m, 'enterBandDraft', function (orig) {
@@ -247,7 +252,11 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
const d = m.draft;
const ids = m.order.map((p) => p.playerId).sort();
if (JSON.stringify(d.order.slice().sort()) !== JSON.stringify(ids)) fail(`draft order ${d.order} != seats ${ids}`);
if (m.isSolo) { if (m.deadline) fail('solo band draft is timed'); } else expectDeadline(gd.timer('bandDraft'), 'BAND_DRAFT');
// one countdown (user playtest #4 item 4): the step's deadline IS the current turn's, BAND_TURN_SECONDS long
if (m.soloUntimed) { if (m.deadline || d.turnDeadline) fail('untimed band draft is timed'); } else {
if (m.deadline !== d.turnDeadline) fail(`BAND_DRAFT: deadline ${m.deadline} is not the turn's ${d.turnDeadline}`);
expectDeadline(BAND_TURN_SECONDS, 'BAND_DRAFT turn');
}
});
return r;
});
@@ -280,7 +289,7 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
if (m.phase !== PHASE.SP_DRAFT || !m.sp) return;
const s = m.sp;
if (s.idx >= s.order.length) return;
if (m.isSolo) { if (m.deadline) fail('solo 机变 is timed'); } else expectDeadline(s.idx === 0 ? gd.timer('spFirst') : gd.timer('spTurn'), `SP_DRAFT turn ${s.idx}`);
if (m.soloUntimed) { if (m.deadline) fail('untimed 机变 is timed'); } else expectDeadline(s.idx === 0 ? gd.timer('spFirst') : gd.timer('spTurn'), `SP_DRAFT turn ${s.idx}`);
});
return res;
});
@@ -306,7 +315,7 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
runInvariants();
check('prep', () => {
if (m.phase !== PHASE.PREP) return;
if (m.isSolo) { if (m.deadline) fail('solo prep is timed'); } else if (!m._prepEndQueued) expectDeadline(gd.prepTime(m.round), 'PREP');
if (m.soloUntimed) { if (m.deadline) fail('untimed prep is timed'); } else if (!m._prepEndQueued) expectDeadline(gd.prepTime(m.round), 'PREP');
});
return res;
});
+13 -4
View File
@@ -12,6 +12,7 @@
// that still carries them is ignored).
import { getConfig, getMode } from '../data.js';
import { isShopItem } from '../sim/simdata.js';
const own = (map, id) => (map && typeof map === 'object' && typeof id === 'string' && Object.hasOwn(map, id) && map[id] && typeof map[id] === 'object' ? map[id] : null);
const numOr = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
@@ -44,7 +45,7 @@ export const DEFAULTS = Object.freeze({
hiddenCore: { single: 350, multi: 1200, minTeamLpExclusive: 1, difficulties: ['NORMAL', 'HARD', 'ABYSS'] },
dp: { init: 10, perSec: 1, max: 99 },
unite: { maxHelpers: 2, templates: { 1: 'act1autochess_escaped_single', 2: 'act1autochess_escaped_multi' } },
timers: { infoCheck: 25, bandDraft: 50, bandTurn: 12, battleCheck: 3, spFirst: 30, spTurn: 16 },
timers: { infoCheck: 25, bandDraft: 50, bandTurn: 30, battleCheck: 3, spFirst: 30, spTurn: 16 },
bans: { FUNNY: { core: 0, addon: 1 }, NORMAL: { core: 3, addon: 4 }, HARD: { core: 3, addon: 4 }, ABYSS: { core: 3, addon: 4 } },
bandDraft: { skipsPerPlayer: 1, timeoutBandId: 'band_bldsk' },
leftoverFundsKeptByBands: ['band_cannot'],
@@ -76,10 +77,14 @@ export class GameData {
const c = chess[id];
return c && c.visible && !c.isGolden && !c.isDiy && !c.isHidden && Number.isInteger(c.tier);
}).sort();
/** shop-eligible normal EQUIP item ids by tier */
/**
* Shop item ids by tier (sim/simdata.js isShopItem: normal EQUIP, not hidden, not effect-only — the special
* 维式重锤 and 突变细胞 are never sold). Every "shop item" draw uses it: the shop item slot (pool.js), the 道具补给 /
* 机密商店 cards (choices.js) and the shop-eligible item pools (Match.rollItemId).
*/
this.shopItemsByTier = {};
for (const [id, it] of Object.entries(this._items)) {
if (!it || it.isGolden || it.hideInShop || it.itemType !== 'EQUIP' || !Number.isInteger(it.tier)) continue;
if (!isShopItem(it)) continue;
(this.shopItemsByTier[it.tier] ||= []).push(id);
}
for (const k of Object.keys(this.shopItemsByTier)) this.shopItemsByTier[k].sort();
@@ -434,7 +439,11 @@ export class GameData {
return b && Number.isInteger(b.totalHp) && b.totalHp > 0 ? b.totalHp : this.defaultStartLp;
}
/** Placeable (hand) tokens a chess sends to the hand when placed on the board: [{ tokenId, count }]. */
/**
* Placeable (hand) tokens a chess sends to the hand when placed on the board: [{ tokenId, count }] — its TALENT
* summons (tokens.json `placeable`: 海嗣 / 狼群 / 流形); a skill's summon (赫默's drone, 巫恋's doll) only appears in
* battle when the skill fires (tools/build-data.mjs buildTokens).
*/
placeableTokens(chessId) {
const c = this.chess(chessId);
if (!c || !Array.isArray(c.tokens)) return [];
+47 -5
View File
@@ -18,13 +18,13 @@
// Robustness: every content callback and every step phase is wrapped; errors are logged once per key and the
// battle continues. After MAX_INTERNAL_ERRORS the battle force-ends as a timeout.
import { TICK, ROWS, COLS, DP_DEFAULTS, 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 } from './constants.js';
import { TICK, ROWS, COLS, DP_DEFAULTS, DOWN_STATE, 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 } from './constants.js';
import { GEO } 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 } from './damage.js';
import { dealDamage as pipeDamage, heal as pipeHeal, applyHpLoss, makeDamageInfo, reduceElement, palsyBuff, elementView } from './damage.js';
import { absoluteRangeKeys, canTargetEnemy } from './targeting.js';
import { normDir, mirrorDir, localOrder, localBefore } from './dir.js';
import { ProjectileSystem } from './projectiles.js';
@@ -1824,7 +1824,12 @@ export class Battle {
return ev;
}
/** Compact full snapshot of this field (DESIGN §8.2 b.snap). */
/**
* Compact full snapshot of this field (DESIGN §8.2 b.snap), plus (only when non-empty):
* down: [[id, respawnAt, respawnTime, state]] — knocked-out operators waiting to redeploy on their own tile
* (isDown): the game time their respawn timer ends, its length (s) and constants.js DOWN_STATE;
* elem: [[id, element, fill, cooldownEnd, cooldown]] — the element gauge each unit shows (damage.js elementView).
*/
snapshot() {
const snap = {
fieldId: this.fieldId,
@@ -1839,14 +1844,51 @@ export class Battle {
for (const p of this.players) snap.dps[p.playerId] = Math.floor(p.dp);
}
if (this.sharedBoss) snap.boss = { hp: Math.max(0, Math.round(this.sharedBoss.hp)), max: Math.round(this.sharedBoss.maxHp) };
const r2 = (v) => Math.round(v * 100) / 100;
let down = null;
for (const u of this.allyUnits) {
if (!this.isDown(u)) continue;
(down || (down = [])).push([u.id, r2(u.respawnAt), r2(Math.max(0, u.respawnAt - u.deathAt)), this._downState(u)]);
}
if (down) snap.down = down;
let elem = null;
for (const u of this.units) {
if (!u.alive || !u.deployed || u.hidden) continue;
const v = elementView(u, this.time);
if (v) (elem || (elem = [])).push([u.id, v[0], v[1], v[2], v[3]]);
}
if (elem) snap.elem = elem;
return snap;
}
/** Field meta for m.field: { fieldId, kind, rect, stageId, units: UnitInfo[] } */
/**
* A knocked-out operator waiting to redeploy on its own tile (DESIGN §5.5: after its respawn time, when the tile is
* free and DP ≥ cost): killed — not withdrawn, not removed for good — after it was deployed. The client keeps its
* model on the field knocked down with a redeploy countdown (b.snap `down`, render/units.js); summons, devices and
* enemies simply leave.
*/
isDown(u) {
return !!u && u.side === 'ally' && u.kind === 'op' && !u.alive && !u.removed && u.removeReason === 'killed'
&& u.deploySeq > 0 && Number.isFinite(u.respawnAt);
}
/** constants.js DOWN_STATE of a down operator: its timer runs, or it waits for its tile / the DP (_checkRedeploys). */
_downState(u) {
if (this.time + 1e-9 < u.respawnAt) return DOWN_STATE.COUNTING;
const occ = this._occ[u.homeR * COLS + u.homeC];
if (occ && occ.alive && occ !== u) return DOWN_STATE.WAIT_TILE;
const ps = this.getPlayer(u.ownerId);
return !ps || ps.dp + 1e-9 < u.base.cost ? DOWN_STATE.WAIT_DP : DOWN_STATE.COUNTING;
}
/**
* Field meta for m.field: { fieldId, kind, rect, stageId, units: UnitInfo[] } — the units on the field, knocked-out
* operators waiting to redeploy included (a client joining mid-battle shows them down).
*/
fieldMeta() {
return {
fieldId: this.fieldId, kind: this.kind, rect: { ...this.rect }, stageId: this.stageId,
units: this.units.filter((u) => u.alive && u.deployed && !u.hidden).map(unitInfo),
units: this.units.filter((u) => (u.alive && u.deployed && !u.hidden) || this.isDown(u)).map(unitInfo),
};
}
+6 -1
View File
@@ -618,8 +618,13 @@ function enemyAttack(b, e) {
const attackId = ++b._attackSeq;
const type = def.dmgType === 'heal' ? 'arts' : def.dmgType;
const rangedShot = radius > 0 && !(e.blockedBy && targets[0] === e.blockedBy && radius < 1);
// content-resolved attacks (`e.profile.deferHit`: 帝国炮火先兆者's shell landing 3 s later): the attack itself happens
// — the 'atk' event (kind `e.profile.shot`, drawn by the content's own fx), cooldown, pause, the 'attack' hook — and
// the content's 'attack' handler deals its damage
const deferred = !!(e.profile && e.profile.deferHit);
for (const t of targets) {
b._ev(['atk', e.id, t.id, rangedShot ? 'enemy' : 'none']);
b._ev(['atk', e.id, t.id, deferred ? (e.profile.shot || 'none') : rangedShot ? 'enemy' : 'none']);
if (deferred) continue;
const hit = (tt) => {
if (!tt || !tt.alive || !e.alive && !rangedShot) return;
b.dealDamage(e, tt, { amount: e.s.atk * (e.profile?.atkScale ?? 1), type, isAttack: true, attackId });
+15 -1
View File
@@ -39,6 +39,9 @@ export const ELEMENTS = Object.freeze(['burn', 'neural', 'necrosis', 'apoptosis'
* `ally` = an operator/summon hit by enemy damage (ba.dt.burning / neural / apoptosis / erosion),
* `enemy` = an enemy hit by operators (the "·我方" terms ba.dt.burning2 / neural2 / apoptosis2 / erosion2;
* `elemDamage` is 元素伤害: HP damage ignoring DEF/RES, × elemTakenMul).
* `duration` = the burst's 爆发冷却 (PRTS 元素 table "持续时间": 10 s, 凋亡 15 s, 侵蚀 on enemies 8 s — the operators'
* 侵蚀 burst has its 10 s cooldown too): while it runs NO element of the unit fills or recovers, and when it ends every
* gauge of the unit resets (damage.js).
* `necrosis` keeps the engine's earlier invented burst (no official counterpart) for legacy content.
*/
export const ELEMENT = Object.freeze({
@@ -58,11 +61,16 @@ export const ELEMENT = Object.freeze({
enemy: Object.freeze({ elemDps: 800, weaken: 0.5, duration: 15 }),
}),
erosion: Object.freeze({
ally: Object.freeze({ damage: 800, type: 'phys', defDown: 100, duration: 0 }),
ally: Object.freeze({ damage: 800, type: 'phys', defDown: 100, duration: 10 }),
enemy: Object.freeze({ elemDamage: 5000, defDown: 120, duration: 8 }),
}),
necrosis: Object.freeze({ dps: 100, duration: 12, atkDownPct: 0.2 }),
});
/**
* Official element order (PRTS 元素: SANITY 1 神经, WATER 2 侵蚀, FIRE 3 灼燃, DARK 4 凋亡; the legacy `necrosis` last):
* the tie-break of the "当前损伤元素" a unit shows — the fullest gauge, then the lower id (damage.js elementView).
*/
export const ELEMENT_ORDER = Object.freeze(['neural', 'erosion', 'burn', 'apoptosis', 'necrosis']);
/** 麻痹 (ba.palsy): each stack cancels one normal attack of an enemy; at most 3 stacks, lasts until consumed. */
export const PALSY_MAX = 3;
@@ -79,6 +87,12 @@ export const FREEZE_RES_DOWN = 15;
/** Default DP rules (DESIGN §5.5), overridable by Battle opts.flags. */
export const DP_DEFAULTS = Object.freeze({ dpInit: 10, dpPerSec: 1, dpMax: 99 });
/**
* State of a knocked-out operator waiting to redeploy on its own tile (b.snap `down` entries, Battle.snapshot): its
* respawn timer runs (COUNTING), then it waits for the player's DP to reach its cost (WAIT_DP) or for its tile to be
* free (WAIT_TILE). render/units.js mirrors these codes.
*/
export const DOWN_STATE = Object.freeze({ COUNTING: 0, WAIT_DP: 1, WAIT_TILE: 2 });
/** Safety cap for battles with an infinite time limit (boss rounds are force-ended by the match). */
export const MAX_BATTLE_TIME = 3600;
+3 -2
View File
@@ -1,7 +1,8 @@
// 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 “炎佑” (30 % of the 炎 ATK / max HP sums
// at battle start); 9: two 炎佑, ATK ×atk, damage taken ×(1 − damage_resistance)
// 炎 yanShip members ATK ×(1 + 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)
// 萨尔贡 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 ≤
+3 -4
View File
@@ -56,6 +56,7 @@
// Attribute bonuses are 直接乘算 (support/index.js convention): ×(1 + x) multipliers; flat values stay flat.
import { gameData, num, buffsOf, passiveBuff, effectRecord } from './support/index.js';
import { isShopItem } from '../simdata.js';
// =====================================================================================================================
// data helpers
@@ -166,10 +167,8 @@ export function choiceCardIds(data = gameData()) {
for (const c of Array.isArray(cards.bounty) ? cards.bounty : []) if (c && c.effectId && !bounty.includes(c.effectId)) bounty.push(c.effectId);
for (const c of Array.isArray(cards.tactic) ? cards.tactic : []) if (c && c.effectId && !tactic.includes(c.effectId)) tactic.push(c.effectId);
const items = [];
for (const [id, it] of Object.entries((data && data.items) || {})) {
if (!it || it.isGolden || it.hideInShop || it.itemType !== 'EQUIP' || !Number.isInteger(it.tier)) continue;
items.push(id);
}
// the 道具补给 / 机密商店 cards draw shop items only (never the effect-only special 维式重锤 / 突变细胞)
for (const [id, it] of Object.entries((data && data.items) || {})) if (isShopItem(it)) items.push(id);
return { bounty: bounty.sort(), tactic: tactic.sort(), items: items.sort() };
}
+101 -26
View File
@@ -69,8 +69,14 @@ const ACBUNN_TARGETS = 3;
const ACPUPP_AURA_RADIUS = 2;
/** 重弩突袭者 直击 reach along a row/column [ASSUMED]. */
const CROSS_REACH = 6;
/** 暴鸰 bomb splash radius [ASSUMED]. */
const BOMB_RADIUS = 1;
/** 暴鸰 投弹: the target's tile and its 8 neighbours (PRTS "对目标及其周围八格的我方单位造成100%物理伤害"). */
const BOMB_REACH = 1;
/** 帝国炮火先兆者 shell (PRTS "普通攻击向目标所在位置发射一枚于3秒后命中的弹道,弹道对半径1.2范围内的所有我方单位造成攻击力
* 100%的无来源物理伤害 … ※弹道始终使用缓存攻击力"): flight time and blast radius. */
const SHELL_FLIGHT = 3, SHELL_RADIUS = 1.2;
/** 假想敌:黑云 抓取: the blackboard radius counts ×2.5 (PRTS "2.5倍可变半径": range_radius 1.5 → 3.75), at most 3 prey,
* "短暂延迟后" the 延迟吞噬 lands (delay [ASSUMED] 0.5 s); its SP (= 全弹发射 hits) caps at the data's spData.maxSp. */
const GRAB_RADIUS_SCALE = 2.5, GRAB_MAX_PREY = 3, GRAB_DELAY = 0.5;
/** 源石污染区 aura radius of an activated 奇美拉 [ASSUMED]. */
const CHIMERA_AURA_RADIUS = 1;
/** Bombardments per 自行炮 Cannon cast ("数次") [ASSUMED]. */
@@ -616,8 +622,9 @@ const selfFear = (ab) => ({
a.done = true;
const fear = T(ab, 'SelfFear.fear') ?? 0;
if (fear > 0) b.applyStatus(e, 'fear', { duration: fear, source: e });
// PRTS “萨科塔之翼/之眼”: "在5s内移动速度最终提升至150%" — move_speed 1.5 is the final multiplier
const ms = T(ab, 'SelfFear.move_speed') ?? 0, sd = T(ab, 'SelfFear.speed_duration') ?? 0;
if (ms > 0 && sd > 0) b.addBuff(e, { key: 'ab:fearRun', duration: sd, mods: { moveMul: 1 + ms }, flags: { unblockable: true } });
if (ms > 0 && sd > 0) b.addBuff(e, { key: 'ab:fearRun', duration: sd, mods: { moveMul: ms }, flags: { unblockable: true } });
},
});
@@ -1226,25 +1233,50 @@ function kitBoneSpike() {
}];
}
/**
* 假想敌:黑云 (PRTS): 抓取 (KillOthers, cd 10 / icd 5) "仅半径3.75范围内存在未持有【延迟吞噬】的敌方飞行普通单位时可触发:
* 选择至多3名满足上述条件的目标,短暂延迟后对其施加4秒【延迟吞噬】:束缚,效果结束时令给予方+1SP,随后强制击杀受予方 ※技能
* 持续4秒,期间持有束缚" (the nearest prey first [ASSUMED]); 全弹发射 (FireWeapon, cd 10 / icd 5, SP cost 1) "仅全场范围内存在
* 我方单位时可触发:清空自身SP,进行一次多连击,每击选择全场范围内的1名随机我方单位,对其造成攻击力130%的物理伤害 ※多连击的
* 连击次数等于本技能消耗的SP数量" — SP (技力上限 spData.maxSp 3) only comes from the grabs.
*/
function kitBlackCloud(ab, e) {
const k = ab.sk.KillOthers, f = ab.sk.FireWeapon;
const maxAmmo = Math.max(1, num(e.def.raw && e.def.raw.sp && e.def.raw.sp.maxSp, 3));
const grabR = (k ? num(k.bb.range_radius, 1.5) : 1.5) * GRAB_RADIUS_SCALE;
const dur = k ? num(k.bb.duration, 4) : 4;
const isPrey = (b, e2, o) => o !== e2 && o.alive && !o.hidden && o.isFlying && o.def.rank === 'NORMAL' && !o.isBoss && !o.findBuff('ab:devoured');
const prey = (b, e2) => b.enemiesInRadius(e2.x, e2.y, grabR).filter((o) => isPrey(b, e2, o));
return [
skill(k, (b, e2, a) => {
const prey = b.enemiesInRadius(e2.x, e2.y, k.bb.range_radius ?? 1.5).filter((o) => o !== e2 && o.isFlying && o.def.rank === 'NORMAL' && !o.isBoss);
for (const o of prey) {
b.addBuff(o, { key: 'ab:devoured', duration: k.bb.duration ?? 0, flags: { bind: true, noMove: true, disarm: true } });
b.after(k.bb.duration ?? 0, () => { if (o.alive && e2.alive) { b.kill(o, null); e2.mem.ab.ammo = (e2.mem.ab.ammo ?? 0) + (k.bb.sp ?? 1); } }, { owner: e2 });
}
if (prey.length) b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: prey[0].id, kind: 'devour' });
}, { sil: true }),
skill(k, (b, e2) => {
const list = prey(b, e2).sort((p, q) => Math.hypot(p.x - e2.x, p.y - e2.y) - Math.hypot(q.x - e2.x, q.y - e2.y)).slice(0, GRAB_MAX_PREY);
if (!list.length) return;
b.addBuff(e2, { key: 'ab:grabbing', duration: dur, flags: { bind: true, noMove: true } }); // 技能持续4秒,期间持有束缚
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: list[0].id, kind: 'devour' });
b.after(GRAB_DELAY, () => {
if (!e2.alive) return;
for (const o of list) {
if (!o.alive) continue;
b.addBuff(o, { key: 'ab:devoured', duration: dur, visible: true, flags: { bind: true, noMove: true, disarm: true } });
b.after(dur, () => {
if (!o.alive || !e2.alive) return;
e2.mem.ab.ammo = Math.min(maxAmmo, (e2.mem.ab.ammo ?? 0) + num(k.bb.sp, 1));
b.kill(o, null);
}, { owner: e2 });
}
}, { owner: e2 });
}, { sil: true, cond: (b, e2) => prey(b, e2).length > 0 }),
skill(f, (b, e2) => {
const t = byPriority(e2, targetsNear(b, e2, f.bb.range_radius ?? e2.base.rangeRadius))[0];
const ammo = e2.mem.ab.ammo ?? 0;
if (!t || !(ammo > 0)) return;
const hits = Math.floor(e2.mem.ab.ammo ?? 0);
if (!(hits > 0)) return;
e2.mem.ab.ammo = 0;
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'fireWeapon', n: ammo });
hurt(b, e2, t, e2.s.atk * (f.bb.atk_scale ?? 1) * ammo, 'phys');
}, { sil: true, cond: (b, e2) => (e2.mem.ab.ammo ?? 0) > 0 && targetsNear(b, e2, f.bb.range_radius ?? e2.base.rangeRadius).length > 0 }),
for (let i = 0; i < hits; i++) {
const t = b.rng.pick(allTargets(b, e2));
if (!t) break;
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'fireWeapon' });
hurt(b, e2, t, e2.s.atk * num(f.bb.atk_scale, 1), 'phys');
}
}, { sil: true, cond: (b, e2) => (e2.mem.ab.ammo ?? 0) >= Math.max(1, num(f.sp, 1)) && allTargets(b, e2).length > 0 }),
];
}
@@ -1263,6 +1295,12 @@ function kitRegen(ab) {
function kitSteal(ab) {
const n = T(ab, 'DamageOrBullet.attack@minus_bullet') ?? 1;
return [selfFear(ab), {
// PRTS “萨科塔之眼”: "不会攻击飞行单位" (the 炎佑 is one) — its next ground target in range instead
before(c, b, e) {
if (!c.targets || !c.targets.some((t) => t.isFlying)) return;
const l = targetsNear(b, e, e.base.rangeRadius).filter((t) => !t.isFlying);
c.targets = byPriority(e, l).slice(0, 1);
},
hitOut(c, b, e) {
if (!c.dmg.isAttack) return;
const t = c.target;
@@ -1287,13 +1325,48 @@ function kitRoar(ab) {
}, { sil: true })];
}
/**
* 暴鸰 (PRTS): "不进行普通攻击"; 投弹 (boomb, cooldown / initCooldown 1) "仅攻击范围内存在我方单位时可触发:对目标及其周围八格
* 的我方单位造成100%物理伤害(对主目标造成物理普通伤害,对溅射目标造成物理溅射伤害,伤害无视迷彩)技能结束后移速最终提升至200%
* ※此技能仅能触发一次,不可沉默" — one bomb on the ranged target (engine priority), then move speed ×boomb.move_speed.
*/
function kitBombd(ab) {
const ms = (ab.sk.boomb && ab.sk.boomb.bb.move_speed) || 0;
const s = ab.sk.boomb;
const ms = (s && s.bb.move_speed) || 0;
const reach = (e) => e.base.rangeRadius || 2;
return [
{ spawn(b, e) { e.profile.noAttack = true; } },
skill(s, (b, e, a) => {
const t = byPriority(e, targetsNear(b, e, reach(e)))[0];
if (!t) return;
a.cd = Infinity; a.left = Infinity; // 仅能触发一次
b.fx('explode', { x: t.x, y: t.y, r: BOMB_REACH + 0.5, kind: 'bomb', tiles: 'box', id: e.id });
hurt(b, e, t, e.s.atk, 'phys');
for (const u of alliesInTiles(b, t.tileR, t.tileC, 'box', BOMB_REACH)) if (u !== t) hurt(b, e, u, e.s.atk, 'phys', { tags: ['splash'] });
if (ms > 0) b.addBuff(e, { key: 'ab:bombRun', mods: { moveMul: ms }, persist: true }); // 移速最终提升至200%
}, { cond: (b, e) => targetsNear(b, e, reach(e)).length > 0 }),
];
}
/**
* 帝国炮火先兆者 / 中枢先兆者 (PRTS): every normal attack fires a shell at the target's position that lands SHELL_FLIGHT s
* later and deals 100 % of the ATK at launch as physical damage without a source to every ally within SHELL_RADIUS
* (it may miss the target that moved away; camouflage does not matter). The attack itself is the engine's (cooldown,
* pause, 'atk' event of kind 'mortar'); the damage is the shell's (ai.js `profile.deferHit`).
*/
function kitShell() {
return [{
dealt(c, b, e, a) {
b.fx('explode', { x: c.target.x, y: c.target.y, r: BOMB_RADIUS, kind: 'bomb' });
for (const u of b.alliesInRadius(c.target.x, c.target.y, BOMB_RADIUS)) if (u !== c.target) hurt(b, e, u, e.s.atk, 'phys');
if (!a.fast && ms > 0) { a.fast = true; b.addBuff(e, { key: 'ab:bombRun', mods: { moveMul: 1 + ms }, persist: true }); } // 投掷之后移动速度大幅度提升
spawn(b, e) { e.profile.deferHit = true; e.profile.shot = 'mortar'; },
attack(c, b, e) {
const atk = e.s.atk * (e.profile.atkScale ?? 1);
for (const t of c.targets) {
const x = t.x, y = t.y;
b.fx('bombardShell', { x, y, id: e.id, r: SHELL_RADIUS, t: SHELL_FLIGHT });
b.after(SHELL_FLIGHT, () => {
b.fx('bombard', { x, y, r: SHELL_RADIUS, kind: 'emppnt' });
for (const u of b.alliesInRadius(x, y, SHELL_RADIUS)) hurt(b, null, u, atk, 'phys', { isSkill: false, tags: ['shell'] });
});
}
},
}];
}
@@ -2026,12 +2099,14 @@ export const KITS = Object.freeze({
enemy_1355_mrfly: (ab, e) => [enemyAura(e.base.rangeRadius || 2.5, 'ab:mrfly', { resFlat: T(ab, 'magdef_add.magic_resistance') ?? 0 })], // 护障 · RES aura
enemy_1355_mrfly_2: (ab, e) => [enemyAura(e.base.rangeRadius || 2.5, 'ab:mrfly', { resFlat: T(ab, 'magdef_add.magic_resistance') ?? 0 })], // 护障·P · RES aura
enemy_1042_frostd: (ab) => [allyAura(T(ab, 'defup.range_radius') ?? 2.5, 'ab:frost', { aspd: (T(ab, 'atkSpeedDown.attack_speed') ?? 0) * 100 })], // 寒霜 · ASPD −50 aura on operators
enemy_1040_bombd: kitBombd, // 暴鸰 · bomb splash; speeds up after throwing
enemy_1040_bombd: kitBombd, // 暴鸰 · no normal attack: ONE bomb (target + 8 tiles), then ×2 speed
enemy_10083_hlbird: kitSelfFear, // “萨科塔之翼” · fear + flee below half HP
enemy_10084_hlegle: kitSteal, // “萨科塔之眼” · fear below half; steals 1 ammo instead of hitting
enemy_10085_hllevi_2: kitRoar, // “萨科塔昂首” · fear below half; 祈祷邀约 global ASPD −30
enemy_1407_hummbd: kitExposeOnDeath, // 远眺 · death: exposes operators around (damage taken ×1.2)
enemy_9009_acfort: kitBlackCloud, // 假想敌:黑云 · devours normal flyers for ammo, fires it all at once
enemy_9009_acfort: kitBlackCloud, // 假想敌:黑云 · devours ≤ 3 normal flyers for ammo, fires it all at random allies
enemy_1112_emppnt: kitShell, // 帝国炮火先兆者 · attacks are shells landing 3 s later (r 1.2)
enemy_1112_emppnt_2: kitShell, // 帝国炮火中枢先兆者 · same
enemy_1321_wdarft: (ab) => [skill(ab.sk.BornBugs, (b, e) => spawnChildren(b, e, 'enemy_1269_nhfly', BUGS_PER_CAST))], // 枯朽萃聚使徒 · spawns 枯朽之种
enemy_1269_nhfly: () => [{ // 枯朽之种 · no normal attack: dives onto a nearby operator and self-destructs
spawn(b, e) { e.profile.noAttack = true; },
@@ -2288,8 +2363,8 @@ export const STATS_ONLY = Object.freeze({
enemy_1006_shield_2: 'heavy defender', enemy_1006_shield_3: 'heavy defender', enemy_1007_slime: 'plain',
enemy_1010_demon: 'plain', enemy_1010_demon_2: 'plain', enemy_1041_lazerd: 'FLY arts (engine)', enemy_1041_lazerd_2: 'FLY arts (engine)',
enemy_1043_zomsbr: 'regen is data (hpRecoveryPerSec 80)', enemy_1061_zomshd: 'regen is data (hpRecoveryPerSec 200)',
enemy_1046_agent: 'plain', enemy_1071_dftman: 'plain', enemy_1092_mdgint: 'plain (bounty)', enemy_1112_emppnt: 'FLY ranged (engine)',
enemy_1112_emppnt_2: 'FLY ranged (engine)', enemy_1251_lysyta: 'R-series armour interaction absent', enemy_1251_lysyta_2: 'same',
enemy_1046_agent: 'plain', enemy_1071_dftman: 'plain', enemy_1092_mdgint: 'plain (bounty)',
enemy_1251_lysyta: 'R-series armour interaction absent', enemy_1251_lysyta_2: 'same',
enemy_1252_lysytb_2: 'same', enemy_1254_lypa_2: 'no listed ability', enemy_1325_cbgpro: 'plain', enemy_1325_cbgpro_2: 'plain',
enemy_1367_dseed: 'altar pulse absent (骸骨拷打者 leaves them inert, uncounted, in place)',
enemy_1381_winman: 'plain', enemy_1381_winman_2: 'plain', enemy_1387_winshd: '封冻/供暖器 zones absent', enemy_1415_mmkabi_2: 'chains absent',
+3 -3
View File
@@ -1149,7 +1149,7 @@ const KITS = {
},
// ---- 3_15 巫恋 · 削弱者 (hidden) — S2 诅咒娃娃: places the doll (auto-placed on the tile of the range covering most
// enemies; replaces any doll of hers still standing, the prep-phase board piece included) whose 3×3 aura lowers
// enemies; replaces any doll of hers still standing — the doll is never a prep hand card) whose 3×3 aura lowers
// enemy ATK/DEF, gone after 15 s; 溃败暗示: low-HP enemies fragile
chess_char_3_15_a: (bb, chess, def) => {
const d = defOf(chess, def);
@@ -1171,8 +1171,8 @@ const KITS = {
const life = num(tok?.skill?.duration, 0) > 0 ? tok.skill.duration : textNum(d.skill?.description, /(\d+)秒后自动销毁/, 15);
const grid = tok?.rangeGrid ?? NINE;
const reach = Math.max(1, ...grid.map((p) => Math.max(Math.abs(p[0]), Math.abs(p[1]))));
// "最多可库存1个" / deploy limit 1: the new doll replaces any doll of hers still standing (the board piece
// placed in the prep phase included) — withdrawn first so its tile is available again
// "最多可库存1个" / deploy limit 1: the new doll replaces any doll of hers still standing — withdrawn first
// so its tile is available again
for (const t of tokensOf(battle, unit, tokenId)) if (t.alive) battle.retreat(t, { reason: 'expired', permanent: true });
const foes = battle.aliveEnemies().filter((e) => !e.hidden);
let best = null, bs = -1, bd = Infinity;
+105 -58
View File
@@ -35,7 +35,8 @@
// do not stack), refilled by shield_ratio_each_trigger /s when the target was not hit for `interval`
// s (always while 凯瑟琳's skill runs, owner bb overwrite_ratio)
// 投递坐标 / 风雪之眼 / 保护目标 inert pieces (no attack; 风雪之眼 untargetable) — their effects belong to the owner kit
// 炎佑 (enemy_9012_acloon) flying ally AI (see spawnYanyou)
// 炎佑 (enemy_9012_acloon) flying ally: flies after the highest-aggro enemy of the field and hovers over it, stays
// put when there is none; 3-target arts + burn on every hit, 元素脆弱 aura, 祛恶之焰 channel (yanyouKit)
// 预备干员-医疗 / Touch (band map characters) generic kit / 恳切福音 kit
// Common rules: deploy limit per owner for tokens running these kits (data `deployLimit`; the oldest is withdrawn),
// `summonKill` hook ({ token, owner, victim }) whenever a token kills, lifetimes never outlive a shorter
@@ -874,30 +875,64 @@ function yanyouFallbackDef(battle) {
return battle.data.rawEnemy?.(TOKEN_IDS.yanyou) ?? null;
}
/**
* 炎佑 (PRTS “炎佑” 级别0(卫戍协议), enemy_database enemy_9012_acloon; user playtest #4 item 12):
* * moves on its own — "持续追踪全场范围内仇恨值最高的敌方单位": it flies after the highest-aggro targetable enemy of
* the whole field (the operator order: taunt, least remaining path, earliest spawned; while 祛恶之焰 channels, its
* locked target) and hovers over it (YANYOU_STOP short of its position; a 2nd 炎佑 of 9 炎 hovers 0.8 row beside the
* first); with no enemy on the field it stays where it is (user playtest #4: "停留在最后在的位置,而不是往一个固定位置
* 返回"; PRTS says it then tracks a random tile — the user's first-hand account wins);
* * normal attack: rangeRadius 2.0, arts, 3 targets ("可同时攻击3个目标", talent 1.attack@max_target);
* * every damage it deals adds burn = ATK × 2.ep_damage_ratio (20 %: "自身造成伤害时,附加攻击力20%的灼燃损伤"; the
* official 下半 notice: "祛恶之焰附带的灼燃损伤调整为基于炎佑的攻击力") — normal attacks AND flame ticks;
* * 元素脆弱 aura: enemies within YANYOU_FRAGILE_RADIUS (1.5, PRTS "自身<固定半径>半径1.5范围内") take element damage
* × 2.damage_scale (1.2);
* * immune to element damage ("即将受到元素损伤前取消此元素损伤");
* * 祛恶之焰 [模式乙] (the 炎 bond's mode, Skill_2; cooldown / initCooldown 15, atk_scale 0.6, hit_duration 20,
* range_radius 1.0): "触发索敌和普通攻击相同:锁定1个目标持续施法,最多持续20秒,每秒对目标周围半径1.0范围内的所有
* 敌方单位造成攻击力60%的法术伤害(范围伤害为中点判定…对被锁定的目标也不例外)※施法期间受到沉默影响后,立即结束技能".
* It fires when a normal attack would (SP = the 15 s cooldown), locks that attack's first target, channels on it
* (no normal attacks meanwhile), deals the 1 s ticks (the first at once) and ends after 20 s, when the locked
* target is gone (dead, left, untargetable — "锁定1个目标…最多持续") or when silenced; the cooldown then runs again
* [ASSUMED: the cooldown counts from the end of the channel, like the engine's timed skills]. While stunned /
* frozen the channel keeps its lock but deals nothing.
*/
const YANYOU_STOP = 0.25; // [ASSUMED] how close it hovers to the tracked enemy (tiles)
const YANYOU_FRAGILE_RADIUS = 1.5; // PRTS "在场期间自身<!--固定半径-->半径1.5范围内的敌方单位受到20%的元素脆弱"
const YANYOU_AURA_EVERY = 0.2; // aura refresh period (s); the status lasts two periods
const YANYOU_PROFILE = Object.freeze({ canHitFly: true });
/** Highest-aggro targetable enemy of the whole field for `u` (operator order, targeting.js sortEnemyTargets). */
function topAggroEnemy(battle, u) {
let best = null, bk = null;
for (const e of battle.enemies) {
if (!canTargetEnemy(u, e, YANYOU_PROFILE)) continue;
const k = [-(e.s.taunt || 0), battle.remainingDistance(e), e.spawnSeq];
if (!bk || k[0] < bk[0] || (k[0] === bk[0] && (k[1] < bk[1] - 1e-9 || (Math.abs(k[1] - bk[1]) <= 1e-9 && k[2] < bk[2])))) { best = e; bk = k; }
}
return best;
}
function yanyouKit(bb, raw) {
// enemy-shaped record: `talents` is one { bb } object, `skills` the enemy skill list
const tal = raw?.talents && !Array.isArray(raw.talents) ? raw.talents.bb ?? {} : raw?.talent ?? {};
const skills = Array.isArray(raw?.skills) ? raw.skills : [];
const sk1 = skills.find((s) => s.prefabKey === 'Skill') ?? skills[0] ?? {};
const sk2 = skills.find((s) => s.prefabKey === 'Skill_2') ?? sk1;
// the 炎 bond summons 模式乙 (Skill_2, PRTS: "由核心盟约-炎召唤时为模式乙"); 模式甲 (Skill) hits the target only
const sk = skills.find((s) => s.prefabKey === 'Skill_2') ?? skills.find((s) => s.prefabKey === 'Skill') ?? skills[0] ?? {};
const maxTargets = Math.max(1, Math.floor(num(tal['1.attack@max_target'], 1)));
const epRatio = num(tal['2.ep_damage_ratio'], 0);
const fragR = num(tal['2.range_radius'], 0);
const fragMul = num(tal['2.damage_scale'], 1);
const radius = num(raw?.stats?.rangeRadius, 0) > 0 ? num(raw.stats.rangeRadius, 0) : 2;
const moveSpeed = num(raw?.stats?.moveSpeed, 1);
const cd = num(sk1.cooldown, 0);
const initCd = num(sk1.initCooldown, cd);
const flameScale = num(sk2.bb?.atk_scale ?? sk1.bb?.atk_scale, 0);
const flameDur = num(sk2.bb?.hit_duration ?? sk1.bb?.hit_duration, 0);
const flameR = num(sk2.bb?.range_radius, 0);
const FLAME_PROFILE = { canHitFly: true };
const inReach = (u, e, pad = 0) => e && e.alive && !e.hidden && Math.hypot(e.x - u.x, e.y - u.y) <= radius + pad + 1e-9;
const bestInReach = (battle, u) => {
const list = battle.enemies.filter((e) => inReach(u, e) && canTargetEnemy(u, e, FLAME_PROFILE));
if (!list.length) return null;
sortEnemyTargets(battle, u, list, null);
return list[0];
const cd = num(sk.cooldown, 0);
const initCd = num(sk.initCooldown, cd);
const flameScale = num(sk.bb?.atk_scale, 0);
const flameDur = num(sk.bb?.hit_duration, 0);
const flameR = num(sk.bb?.range_radius, 0);
/** The channel's locked target while it is still valid (alive, on the field, targetable), else null. */
const lockOf = (u) => {
const t = u.mem.flame?.target;
return t && t.alive && canTargetEnemy(u, t, YANYOU_PROFILE) ? t : null;
};
const refreshKeys = (u) => {
// cached while it hovers in place — unless the engine rebuilt the range from the (dummy) grid meanwhile
@@ -917,63 +952,73 @@ function yanyouKit(bb, raw) {
return {
skill: cd > 0 && flameDur > 0 ? {
kind: 'duration', duration: flameDur, spType: 'time', spCost: cd, initSp: Math.max(0, cd - initCd), trigger: 'DEFAULT',
onStart({ unit }) { unit.mem.flame = { target: null, acc: 1 }; },
attack: { noAttack: true }, // channelling: no normal attacks
onStart({ battle, unit }) {
// "触发索敌和普通攻击相同": the normal attack's first target
const list = battle.enemiesInKeys(unit.rangeKeys, unit, YANYOU_PROFILE);
sortEnemyTargets(battle, unit, list, null);
unit.mem.flame = { target: list[0] ?? null, acc: 1 };
if (!list[0]) unit.skill.end('noTarget');
},
onTick({ battle, unit, dt }) {
const f = unit.mem.flame || (unit.mem.flame = { target: null, acc: 1 });
const f = unit.mem.flame;
if (!f) return;
if (unit.s.flags.silence) { unit.skill.end('silence'); return; }
const t = lockOf(unit);
if (!t) { unit.skill.end('targetLost'); return; }
if (!unit.canAct) return; // stunned / frozen: the lock holds, no damage
f.acc += dt ?? battle.dt;
if (f.acc < 1 - 1e-9) return;
f.acc -= 1;
if (!inReach(unit, f.target, 0.5) || !canTargetEnemy(unit, f.target, FLAME_PROFILE)) f.target = bestInReach(battle, unit);
const t = f.target;
if (!t) { unit.skill.end('noTarget'); return; }
// 祛恶之焰 (Skill_2 range_radius): the channelled target and every enemy within range_radius of it
// (research 02 §3.1; the talent's 3-target cap is for normal attacks)
const others = flameR > 0 ? battle.enemiesInRadius(t.x, t.y, flameR).filter((e) => e !== t) : [];
for (const e of [t, ...others]) battle.dealDamage(unit, e, { amount: unit.s.atk * flameScale, type: 'arts', isSkill: true, tags: ['flame'] });
battle.fx('yanyouFlame', { x: t.x, y: t.y, id: unit.id, target: t.id, n: 1 + others.length });
// centre-point AoE around the locked target, searched again every second (the target itself included)
const hit = flameR > 0 ? battle.enemiesInRadius(t.x, t.y, flameR) : [];
if (!hit.includes(t)) hit.unshift(t);
for (const e of hit) battle.dealDamage(unit, e, { amount: unit.s.atk * flameScale, type: 'arts', isSkill: true, tags: ['flame'] });
battle.fx('yanyouFlame', { x: t.x, y: t.y, id: unit.id, target: t.id, r: flameR, n: hit.length, dur: 1 });
},
onEnd({ unit }) { unit.mem.flame = null; },
} : null,
trait: {
attack: 'ranged', dmgType: 'arts', projectile: 'bolt', canHitFly: true, maxTargets, heal: null, noAttack: false,
afterHit: (b, u, target, info) => {
// 元素脆弱 (same-name statuses keep the strongest); it lasts until shortly after its next attack [ASSUMED: no data]
if (fragR > 0 && fragMul > 1) {
for (const e of b.enemiesInRadius(info.x, info.y, fragR)) b.applyStatus(e, 'elemFragile', { duration: u.s.interval + 0.5, value: fragMul - 1, source: u });
}
if (epRatio > 0 && target && target.alive) b.dealDamage(u, target, { type: 'element', element: 'burn', amount: u.s.atk * epRatio, tags: ['yanyou'] });
},
},
trait: { attack: 'ranged', dmgType: 'arts', projectile: 'bolt', canHitFly: true, maxTargets, heal: null, noAttack: false },
install(battle, unit) {
unit.motion = 'FLY';
if (!(unit.base.spRecovery > 0)) unit.base.spRecovery = 1; // the cooldown ticks as time SP (data spRecovery 0)
unit.base.blockCnt = 0;
const speed = moveSpeed * MOVE_SCALE;
onDeploy(battle, unit, () => { unit.mem.keysAt = null; refreshKeys(unit); });
// every damage it deals adds burn (not the burn itself, not element bursts)
if (epRatio > 0) {
battle.on('damaged', (ctx) => {
if (ctx.source !== unit || !ctx.target || ctx.target.side !== 'enemy' || !ctx.target.alive) return;
if (ctx.type === 'element' || ctx.type === 'elemental' || !(ctx.amount > 0)) return;
battle.dealDamage(unit, ctx.target, { type: 'element', element: 'burn', amount: unit.s.atk * epRatio, tags: ['yanyou'] });
}, { owner: unit });
}
// "即将受到元素损伤前取消此元素损伤"
battle.on('elementHit', (ctx) => { if (ctx.target === unit) ctx.dmg.cancel = true; }, { owner: unit });
let auraAcc = YANYOU_AURA_EVERY;
battle.on('tick', ({ dt }) => {
if (!unit.alive || !unit.deployed) return;
if (unit.canAct && !unit.s.flags.noMove && speed > 0) {
// chase the leading enemy (or the flame target) and hover over the middle of the enemies around it, so
// its multi-target attacks reach as many as possible
const tgt = unit.mem.flame?.target && unit.mem.flame.target.alive ? unit.mem.flame.target : mostAdvancedEnemy(battle);
let tx = unit.homeC, ty = unit.homeR;
const tgt = lockOf(unit) ?? topAggroEnemy(battle, unit);
if (tgt) {
let sx = 0, sy = 0, n = 0;
for (const e of battle.enemiesInRadius(tgt.x, tgt.y, radius)) { sx += e.x; sy += e.y; n++; }
tx = n ? sx / n : tgt.x; ty = n ? sy / n : tgt.y;
ty += num(unit.mem.hoverDy, 0); // a 2nd 炎佑 hovers beside the first one
}
const dx = tx - unit.x, dy = ty - unit.y;
const d = Math.hypot(dx, dy);
const stop = tgt ? 0.25 : 0;
if (d > stop + 1e-6) {
const step = Math.min(d - stop, speed * dt);
const R = battle.rect;
unit.x = Math.max(R.c0, Math.min(R.c1, unit.x + (dx / d) * step));
unit.y = Math.max(R.r0, Math.min(R.r1, unit.y + (dy / d) * step));
const dx = tgt.x - unit.x, dy = tgt.y + num(unit.mem.hoverDy, 0) - unit.y;
const d = Math.hypot(dx, dy);
if (d > YANYOU_STOP + 1e-6) {
const step = Math.min(d - YANYOU_STOP, speed * dt);
const R = battle.rect;
unit.x = Math.max(R.c0, Math.min(R.c1, unit.x + (dx / d) * step));
unit.y = Math.max(R.r0, Math.min(R.r1, unit.y + (dy / d) * step));
}
}
}
refreshKeys(unit);
auraAcc += dt;
if (fragMul > 1 && auraAcc >= YANYOU_AURA_EVERY - 1e-9) {
auraAcc = 0;
for (const e of battle.enemiesInRadius(unit.x, unit.y, YANYOU_FRAGILE_RADIUS)) {
battle.applyStatus(e, 'elemFragile', { duration: 2 * YANYOU_AURA_EVERY, value: fragMul - 1, source: unit });
}
}
}, { owner: unit });
},
};
@@ -1003,8 +1048,9 @@ function airTile(battle, playerId, taken) {
/**
* 炎 bond summon: spawn `count` “炎佑” for `playerId` (flying ally that picks its own targets; bonds.js computes the
* numbers). atk / hp replace the template stats (0.3 × 炎 sums at battle start), atkMul (9 炎: ×1.5) and
* dmgTakenMul (9 炎: 0.1) are applied as a persistent buff. Returns the spawned units.
* numbers). atk / hp (0.3 × the 炎 sums at battle start) are ADDED to the template stats (600 / 12000): PRTS "登场时使
* 自身攻击力、生命值增加召唤自身的玩家场地上的所有【炎】盟约干员攻击力、生命值的30%(最终加算)". atkMul (9 炎: ×1.5,
* [ASSUMED] on the whole ATK) and dmgTakenMul (9 炎: 0.1) are applied as a persistent buff. Returns the spawned units.
*/
export function spawnYanyou(battle, playerId, { atk, hp, atkMul = 1, dmgTakenMul = 1, count = 1 } = {}) {
const rec = battle.data.rawToken?.(TOKEN_IDS.yanyou) ?? battle.data.rawEnemy?.(TOKEN_IDS.yanyou) ?? null;
@@ -1013,9 +1059,10 @@ export function spawnYanyou(battle, playerId, { atk, hp, atkMul = 1, dmgTakenMul
const n = Math.max(0, Math.min(Math.floor(num(count, 1)), Math.max(1, Math.floor(num(rec.deployLimit ?? rec.stats?.deployLimit, 2)))));
const out = [];
const taken = new Set();
const base = rec.stats || {};
const stats = {};
if (num(atk, -1) >= 0) stats.atk = num(atk, 0);
if (num(hp, 0) > 0) stats.maxHp = num(hp, 1);
if (num(atk, -1) >= 0) stats.atk = num(base.atk, 0) + num(atk, 0);
if (num(hp, 0) > 0) stats.maxHp = num(base.maxHp, 0) + num(hp, 1);
const ps = battle.getPlayer(playerId);
for (let i = 0; i < n; i++) {
const tile = airTile(battle, playerId, taken);
@@ -1027,7 +1074,7 @@ export function spawnYanyou(battle, playerId, { atk, hp, atkMul = 1, dmgTakenMul
if (num(atkMul, 1) !== 1 && num(atkMul, 1) >= 0) mods.atkMul = num(atkMul, 1);
if (num(dmgTakenMul, 1) !== 1 && num(dmgTakenMul, 1) >= 0) mods.dmgTakenMul = num(dmgTakenMul, 1);
if (Object.keys(mods).length) battle.addBuff(u, { key: 'bond:yanyou', persist: true, allowDead: true, mods });
u.mem.hoverDy = out.length === 0 ? 0 : (out.length % 2 ? 0.8 : -0.8);
u.mem.hoverDy = out.length === 0 ? 0 : (out.length % 2 ? 0.8 : -0.8); // the 2nd one hovers beside the first
u.hp = u.s.maxHp;
battle.fx('yanyouSummon', { x: u.x, y: u.y, id: u.id, playerId });
out.push(u);
+74 -27
View File
@@ -7,22 +7,25 @@
// → 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
// R′ = max(0, R×(1−resIgnorePct) − resIgnoreFlat); True: A.
// Element damage ('element' type + element) fills a gauge instead of HP (1000; enemy leaders 2000). A full gauge
// bursts with the official term-table effects (constants.js ELEMENT), which differ by the side hit:
// operators (enemy damage): burn 1200 arts + RES −20 10 s · neural 1000 true + stun 10 s · apoptosis 15 s: no skill
// activation, −1 SP/s, 100 arts/s · erosion 800 phys + permanent DEF −100.
// enemies (operator damage, "·我方"): burn 7000 元素伤害 + RES −20 (10 s) · neural 6000 元素伤害 + 3 麻痹 (10 s) ·
// Element damage ('element' type + element) fills a gauge instead of HP (1000; enemy leaders 2000), reduced by the
// target's 损伤抵抗 (data `epResistance`, a percentage: PRTS 元素 "受到的元素损伤 = 损伤值 × (1 − 损伤抵抗 × 0.01)").
// A full gauge bursts with the official term-table effects (constants.js ELEMENT), which differ by the side hit:
// operators (enemy damage): burn 1200 arts + RES −20 10 s · neural stun 10 s, then 1000 true · apoptosis 15 s: 阻回
// (noSp) + 静默 (no skill activation), −1 SP/s, 100 arts/s · erosion permanent DEF −100, then 800 phys (10 s).
// enemies (operator damage, "·我方"): burn 7000 元素伤害 + RES −20 (10 s) · neural 3 麻痹, then 6000 元素伤害 (10 s) ·
// apoptosis 15 s: 50 % weaken recovering over the burst + 800 元素伤害/s · erosion 5000 元素伤害 + permanent DEF −120 (8 s).
// necrosis (legacy spare gauge): 12 s of 100 true dmg/s and ATK −20 %.
// The gauge stays locked (full) for the burst duration ("冷却") and resets to 0 when it ends. A burst that is still
// resolving (its `elementBurst` hook runs before the `<el>Burst` lock exists; erosion on operators has no lock at all)
// already counts as locked (`unit.burstPending[el]`): same-element fills of that unit are refused until it has resolved,
// so a hook that spreads the element back (淤困 parasite hosts next to each other) cannot re-burst it recursively.
// 爆发冷却 (PRTS 元素, "阻回" after a burst): for the burst's duration (the `<el>Burst` buff) NO element of the unit can
// fill or be recovered (reduceElement) — the bursting gauge shows full — and when it ends EVERY gauge of the unit
// resets to 0. A burst that is still resolving (its `elementBurst` hook runs before the `<el>Burst` lock exists) already
// counts as locked (`unit.burstPending[el]`), so a hook that spreads an element back (淤困 parasite hosts next to each
// other) cannot re-burst the unit recursively. The unit's shown gauge (the fullest, "当前损伤元素") and its cooldown
// travel in b.snap `elem` (elementView).
// 元素伤害 (HP damage of an element) is the DamageInfo type 'elemental' (+ optional `element` for display): no DEF/RES,
// no dodge, × source dmgDealtMul × target dmgTakenMul × elemTakenMul. Sleeping units (沉睡: 无敌) take no damage
// unless the attacker's profile has `hitSleep` or the damage carries `ignoreSleep`.
import { MIN_DAMAGE_RATIO, ELEMENT, PALSY_MAX } from './constants.js';
import { MIN_DAMAGE_RATIO, ELEMENT, ELEMENT_ORDER, PALSY_MAX } from './constants.js';
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
@@ -201,9 +204,46 @@ export function applyHpLoss(battle, source, target, amount, dmg) {
return dealt;
}
/** Is `target`'s `el` gauge locked: its `<el>Burst` buff is up, or its burst is resolving right now (see header). */
export function burstLocked(target, el) {
return !!(target.burstPending && target.burstPending[el]) || !!target.findBuff(el + 'Burst');
/**
* Are `target`'s element gauges locked (爆发冷却, see header): a burst's `<el>Burst` lock is up (any element — the lock
* holds every gauge of the unit), or a burst of the unit is resolving right now. The element argument of older callers
* is ignored.
*/
export function burstLocked(target) {
const p = target.burstPending;
if (p) for (const k in p) if (p[k]) return true;
return !!target.s.flags.burstLock;
}
/** The burst lock buff (爆发冷却) running on `u`, or null. */
function burstLockOf(u) {
let best = null;
for (const b of u.buffs) if (b.flags && b.flags.burstLock && (!best || b.timeLeft > best.timeLeft)) best = b;
return best;
}
/**
* The element gauge a unit shows (b.snap `elem`, render/units.js): null when every gauge is empty and no burst
* cooldown runs. Official display (PRTS 元素): one icon, the "当前损伤元素" — the fullest gauge (ties: the lower
* official element id, constants.js ELEMENT_ORDER) — with its remaining EP; during a 爆发冷却 the bursting element,
* refilling over the cooldown. Returns `[el, fill 0..1 (2 decimals), cooldownEnd (game s), cooldown (s)]`; the two
* cooldown numbers are 0 outside a cooldown. `now` = the battle time.
*/
export function elementView(u, now) {
const e = u.elem;
if (!e) return null;
const lock = u.s.flags.burstLock ? burstLockOf(u) : null;
if (lock) {
const el = lock.key.endsWith('Burst') ? lock.key.slice(0, -5) : 'burn';
const dur = Number.isFinite(lock.duration) && lock.duration > 0 ? lock.duration : 0;
const left = Number.isFinite(lock.timeLeft) ? Math.max(0, lock.timeLeft) : 0;
return [el in e ? el : 'burn', 1, Math.round((now + left) * 100) / 100, Math.round(dur * 100) / 100];
}
let best = null, bv = 0;
for (const k of ELEMENT_ORDER) { const v = e[k]; if (v > bv) { bv = v; best = k; } }
if (!best) return null;
const fill = Math.min(1, Math.max(0.01, Math.round((bv / u.gaugeMax) * 100) / 100));
return [best, fill, 0, 0];
}
/** Element gauge accumulation + burst. Fires `elementHit` { source, target, dmg } first (mutable amount/mul, cancel). */
@@ -218,7 +258,8 @@ export function applyElement(battle, source, target, dmg) {
if (dmg.type !== 'element') return dealDamage(battle, source, target, dmg); // a handler converted it
}
const max = target.gaugeMax;
let amt = dmg.amount * dmg.mul * target.s.elemTakenMul * (1 - clamp01(target.def?.epDamageResistance ?? 0));
// 损伤抵抗 (official EP_RESISTANCE = data epResistance, a percentage)
let amt = dmg.amount * dmg.mul * target.s.elemTakenMul * (1 - clamp01((target.def?.epResistance ?? 0) / 100));
if (!(amt > 0) || !Number.isFinite(amt)) return 0;
target.elem[el] = Math.min(max, target.elem[el] + amt);
battle._ev(['dmg', target.id, Math.round(amt), el]);
@@ -229,11 +270,12 @@ export function applyElement(battle, source, target, dmg) {
}
/**
* A burst (see header): side-aware official effects; the `<el>Burst` buff locks the gauge for its duration. The unit
* is marked `burstPending[el]` while the burst resolves (hook, lock, hits), so nothing re-bursts it in the meantime.
* A burst (see header): side-aware official effects; the `<el>Burst` buff is the 爆发冷却 that locks every gauge of the
* unit for its duration. The unit is marked `burstPending[el]` while the burst resolves (hook, lock, hits), so nothing
* re-bursts it in the meantime.
*/
export function elementBurst(battle, source, target, el) {
if (!ELEMENT[el] || !target.alive || burstLocked(target, el)) return;
if (!ELEMENT[el] || !target.alive || burstLocked(target)) return;
const pending = target.burstPending || (target.burstPending = {});
pending[el] = true;
try { resolveBurst(battle, source, target, el); } finally { pending[el] = false; }
@@ -242,7 +284,8 @@ export function elementBurst(battle, source, target, el) {
function resolveBurst(battle, source, target, el) {
const cfg = ELEMENT[el];
target.elem[el] = target.gaugeMax;
const reset = () => { target.elem[el] = 0; };
// the cooldown's end restores every element of the unit ("随后单位所有种类的元素值恢复至最大值")
const reset = () => { for (const k of ELEMENT_ORDER) if (k in target.elem) target.elem[k] = 0; };
battle.fx('burst', { x: target.x, y: target.y, id: target.id, element: el });
if (battle._hooks.elementBurst) battle.emit('elementBurst', { source, target, element: el });
if (!target.alive) { reset(); return; }
@@ -265,13 +308,14 @@ function resolveBurst(battle, source, target, el) {
lock(c.duration, { mods: { resFlat: -c.resDown } });
if (enemy) hit(c.elemDamage, 'elemental'); else hit(c.damage, c.type);
} else if (el === 'neural') {
// PRTS 元素: the status first ("立刻获得等时长的眩晕" / "获得3层麻痹"), then the hit
lock(c.duration);
if (enemy) {
hit(c.elemDamage, 'elemental');
if (target.alive) battle.applyStatus(target, 'palsy', { value: c.palsy, source });
battle.applyStatus(target, 'palsy', { value: c.palsy, source });
if (target.alive) hit(c.elemDamage, 'elemental');
} else {
hit(c.damage, c.type);
if (target.alive) battle.applyStatus(target, 'stun', { duration: c.stun, source, force: true });
battle.applyStatus(target, 'stun', { duration: c.stun, source, force: true });
if (target.alive) hit(c.damage, c.type);
}
} else if (el === 'apoptosis') {
if (enemy) {
@@ -285,9 +329,9 @@ function resolveBurst(battle, source, target, el) {
},
});
} else {
// 15 s: no skill activation, −1 SP per second, 100 arts damage per second
// 15 s of 阻回 ("停止并阻止任意形式的技力回复": noSp) + 静默 (no skill activation), −1 SP and 100 arts damage per second
lock(c.duration, {
flags: { silence: true }, interval: 1,
flags: { silence: true, noSp: true }, interval: 1,
onTick: () => {
const sk = target.skill;
if (sk && !sk.noSkill && sk.kind !== 'passive' && !(sk.active && sk.isTimed) && sk.sp > 0) sk.sp = Math.max(0, sk.sp - c.spLossPerSec);
@@ -296,7 +340,7 @@ function resolveBurst(battle, source, target, el) {
});
}
} else if (el === 'erosion') {
// the permanent DEF cut lands first, then the hit
// the permanent DEF cut lands first, then the hit; both sides then have their cooldown (operators 10 s, enemies 8 s)
lock(c.duration);
battle.addBuff(target, { key: 'erosionDown', refresh: 'stack', stacks: 1, maxStacks: 1e6, mods: { defFlat: -c.defDown }, visible: true });
if (enemy) hit(c.elemDamage, 'elemental'); else hit(c.damage, c.type);
@@ -309,12 +353,15 @@ export function palsyBuff(n) {
return { key: 'palsy', refresh: 'stack', stacks, maxStacks: PALSY_MAX, visible: true, status: 'palsy' };
}
/** Reduce an element gauge (e.g. wandermedic "回复元素损伤"). Returns the amount removed. */
/**
* Reduce an element gauge (e.g. wandermedic "回复元素损伤"). Returns the amount removed. Nothing recovers during a
* burst cooldown ("爆发冷却状态下,单位所有类型的元素值均无法损失、无法被其他手段回复").
*/
export function reduceElement(target, amount, el = null) {
let removed = 0;
if (!target || !target.elem || burstLocked(target)) return 0;
const els = el ? [el] : Object.keys(target.elem);
for (const k of els) {
if (burstLocked(target, k)) continue;
const take = Math.min(target.elem[k], amount - removed);
if (take > 0) { target.elem[k] -= take; removed += take; }
if (removed >= amount) break;
+10
View File
@@ -44,6 +44,16 @@ export function flattenBlackboard(bb) {
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d));
/**
* Is an items.json record a shop item — what the 调度中心 item slot, the 道具补给 / 机密商店 cards and the "random
* item" pools (pool_equip_normal …) draw from: a normal EQUIP item with a tier, neither hidden by the official shop
* table (`hideInShop`: the Arts) nor one of the effect-only items build-data marks `shopExcluded` (the special
* 维式重锤 of 维多利亚 / 洛洛, 突变细胞 — tools/build-data.mjs SHOP_EXCLUDED_ITEMS).
*/
export function isShopItem(it) {
return !!it && typeof it === 'object' && !it.isGolden && !it.hideInShop && !it.shopExcluded && it.itemType === 'EQUIP' && Number.isInteger(it.tier);
}
function toArrayOfPairs(grid) {
if (!Array.isArray(grid)) return null;
const out = [];
+4 -1
View File
@@ -1,7 +1,8 @@
// server/sim/skills.js — skill runtime: SP, charges, trigger rules, kinds, SkillSpec interpretation (DESIGN §5.6).
//
// SP types: 'time' (+spRecovery/s), 'attack' (+1 per attack), 'hurt' (+1 per hit taken), 'none'.
// No SP gain while a duration/ammo/toggle skill is active, while stunned, or while the unit has the noSp flag.
// No SP gain while a duration/ammo/toggle skill is active, while stunned, or while the unit has the noSp flag (阻回: no SP
// gain of any kind — time, attack, hurt or granted).
// Charges (maxCharges > 1): SP fills to spCost → +1 charge (SP restarts) until charges == max (SP stays full).
// Trigger rules: DEFAULT (ready + about to attack/heal + enemy / injured ally in the INITIAL range — or, checked every
// tick, an enemy inside one of the content trigger ranges added with addTriggerRange: 海嗣, 流形),
@@ -176,6 +177,8 @@ export class SkillRuntime {
gainSp(amount, reason = 'time', silent = false) {
if (this.noSkill || this.kind === 'passive' || !(amount > 0)) return 0;
if (this.active && this.isTimed && reason !== 'init') return 0;
// 阻回 (the operators' 凋亡 burst, damage.js): "停止并阻止任意形式的技力回复" — no SP of any kind (time, attack, hurt, gifts)
if (reason !== 'init' && this.unit.s.flags.noSp) return 0;
let cost = this.spCost;
if (this.charges >= this.maxCharges && this.sp >= cost) return 0;
let amt = amount;