Merge upstream 0.2.2 and resolve reroll conflicts (#408)

This commit is contained in:
Siornya committed 2026-10-10 00:46:46 +08:00
commit be095d8638
447 files changed
+33376 -8843

No files matched your search

+29 -4
View File
@@ -14,18 +14,43 @@ import fs from 'node:fs';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { APP_VERSION, DEV_BUILD } from '../../shared/constants.js';
import { limitKeyOf } from '../net.js';
import { applyPendingUpdate } from '../update.js';
import { ROOT } from './config.js';
/** Non-internal IPv4 addresses as http URLs. @param {number} port */
/**
* Non-internal addresses as http URLs — IPv4 first, then IPv6 (an IPv6 literal needs brackets: `http://[240e:…]:3000`).
* Link-local (`fe80::`) is left out: a URL cannot carry the zone id. Several privacy addresses in one /64 collapse
* to one URL (`limitKeyOf`).
* @param {number} port
*/
export function lanUrls(port) {
const out = [];
const v4 = [];
const v6 = [];
const v6Seen = new Set();
for (const addrs of Object.values(os.networkInterfaces())) {
for (const a of addrs || []) {
if ((a.family === 'IPv4' || a.family === 4) && !a.internal) out.push(`http://${a.address}:${port}`);
if (a.internal) continue;
if (a.family === 'IPv4' || a.family === 4) { v4.push(`http://${a.address}:${port}`); continue; }
if (a.family !== 'IPv6' && a.family !== 6) continue;
if (/^fe80:/i.test(a.address)) continue;
const prefix = limitKeyOf(a.address);
if (v6Seen.has(prefix)) continue;
v6Seen.add(prefix);
v6.push(`http://[${a.address}]:${port}`);
}
}
return out;
return [...v4, ...v6];
}
/**
* The host as it appears in the local URL. A wildcard bind (`0.0.0.0` or `::`) reads `localhost`. An IPv6 literal
* is bracketed (`http://[2001:db8::1]:3000`); an IPv4 address or a name is kept.
* @param {string} host
*/
export function displayHost(host) {
if (host === '0.0.0.0' || host === '::') return 'localhost';
return host.includes(':') ? `[${host}]` : host;
}
/** Is the module whose `import.meta.url` is `metaUrl` the file node was started with? */
+5 -4
View File
@@ -15,11 +15,12 @@ import { ROOT } from './config.js';
* without this signal a deployed fix could never reach a player who does not reload — a client-only battle fix
* shipped exactly that way and stayed invisible on a page that had been opened before the deploy.
*
* `server/`, `data/` and `shared/` are deliberately NOT in here: this process read them once at startup, so when they
* change without a restart the server still runs the old simulation and data — a page that reloaded into the new files
* would be out of step with the server that validates its battles (and DEPLOY.md restarts the server for every update).
* The browser also imports `/sim/` from `server/sim/` and `/shared/`, and caches JSON from `/data/`. Include those
* trees so a simulation-only or data-only deploy reaches old pages after the server restarts. `buildTag()` caches
* this fingerprint for the process lifetime: files changing on disk do not advertise a new build before restart.
* The rest of `server/` and downloaded media are not included.
*/
export const BUILD_INPUTS = Object.freeze(['public/index.html', 'public/js', 'public/css']);
export const BUILD_INPUTS = Object.freeze(['public/index.html', 'public/js', 'public/css', 'server/sim', 'shared', 'data']);
/** Names the static server never serves: dot files (`.DS_Store`, `.main.js.swp`) and editor backups (`main.js~`). */
const isIgnoredBuildName = (name) => name.startsWith('.') || name.endsWith('~');
+21 -3
View File
@@ -1,6 +1,6 @@
// server/http/config.js — where the server's settings come from. startServer() options win over the environment:
//
// * PORT (default 3000), HOST (default 0.0.0.0);
// * PORT (default 3000), HOST (default '::', one dual-stack socket for IPv6 and IPv4);
// * TRUST_PROXY ('auto' default: honour CF-Connecting-IP / X-Real-IP / X-Forwarded-For only from loopback/private
// peers such as a local cloudflared; '1' always; '0' never) → net.js trustProxy;
// * DEBUG → the console logger's debug level;
@@ -21,18 +21,36 @@ const NET_OPTION_KEYS = ['reconnectWindowMs', 'heartbeatMs', 'helloTimeoutMs', '
const LOBBY_OPTION_KEYS = ['lobbyGraceMs', 'maxRooms', 'maxRoomsPerAddr', 'maxMatchesPerAddr', 'resyncMinGapMs', 'soloReconnectWindowMs'];
/**
* Where to listen: the `port` / `host` options, else PORT / HOST, else port 3000 on 0.0.0.0.
* Bind address used when neither the `host` option nor `HOST` says otherwise: one dual-stack socket, so the server
* answers IPv6 and IPv4 alike without a second listener (Node keeps `ipv6Only` off for `::`).
* `HOST=0.0.0.0` still means IPv4 only, `HOST=127.0.0.1` still means loopback only (a reverse proxy in front).
*/
export const DEFAULT_BIND_HOST = '::';
/**
* Where to listen: the `port` / `host` options, else PORT / HOST, else port 3000 on DEFAULT_BIND_HOST.
* An empty host is treated as unset.
* @param {{ port?: number, host?: string }} opts
* @returns {{ port: number, host: string }}
* @throws {RangeError} when the port is not an integer in 0…65535
*/
export function listenAddress(opts) {
const port = opts.port ?? (process.env.PORT != null && process.env.PORT !== '' ? Number(process.env.PORT) : 3000);
const host = opts.host ?? process.env.HOST ?? '0.0.0.0';
const host = (opts.host || process.env.HOST) || DEFAULT_BIND_HOST;
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new RangeError(`invalid PORT ${port}`);
return { port, host };
}
/**
* The hosts to try, in order, for a bind. Only the default is retried: a host with IPv6 switched off refuses `::`
* with EAFNOSUPPORT / EADDRNOTAVAIL / EINVAL, and falling back to IPv4 beats not booting. An explicit HOST is literal.
* @param {string} host a host out of listenAddress()
* @returns {string[]}
*/
export function bindCandidates(host) {
return host === DEFAULT_BIND_HOST ? [DEFAULT_BIND_HOST, '0.0.0.0'] : [host];
}
/**
* The directories the static server reads (packsDir: the pack folders, server/packs.js).
* @param {{ publicDir?: string, dataDir?: string, sharedDir?: string, packsDir?: string }} opts
+33 -12
View File
@@ -1,7 +1,7 @@
// server/index.js — process entry & boot (DESIGN §1, §2). Plain node:http + ws, no framework: startServer() below
// wires the modules under server/http/, in this order —
//
// http/config.js ROOT, the served directories, the environment (PORT 3000, HOST 0.0.0.0, TRUST_PROXY auto, DEBUG),
// http/config.js ROOT, the served directories, the environment (PORT 3000, HOST '::' dual-stack, TRUST_PROXY auto, DEBUG),
// which startServer() options go to net.js / lobby.js, the console logger
// http/websocket.js session wiring (SessionRegistry → Lobby → Network) and the WebSocket at /ws (maxPayload 64 KB;
// refused at upgrade with 404 / 429 per network / 503)
@@ -24,7 +24,7 @@
import http from 'node:http';
import { getData, loadData } from './data.js';
import { ROOT, listenAddress, serveDirs, makeLogger, parseTrustProxy } from './http/config.js';
import { ROOT, listenAddress, bindCandidates, serveDirs, makeLogger, parseTrustProxy } from './http/config.js';
import { WS_MAX_PAYLOAD, createSessionStack, attachWebSocket } from './http/websocket.js';
import { DATA_SHIM_JS, createStaticHandler } from './http/static.js';
import { createPackRegistry } from './packs.js';
@@ -32,7 +32,7 @@ import { MIME, COMPRESSIBLE, acceptsGzip, parseRange } from './http/files.js';
import { BUILD_INPUTS, computeBuildTag, buildTag, resetBuildTag } from './http/buildTag.js';
import { createRequestHandler } from './http/routes.js';
import { answerClientError } from './http/common.js';
import { lanUrls, isProcessEntry, runMain } from './http/boot.js';
import { lanUrls, displayHost, isProcessEntry, runMain } from './http/boot.js';
// The public API of this module (tests and tools import it from here); the code lives in ./http/.
export {
@@ -77,14 +77,35 @@ export async function startServer(opts = {}) {
server.on('clientError', answerClientError);
const wss = attachWebSocket(server, { network, log });
// The address actually bound. The default may fall back to IPv4; the returned host and url follow that.
let boundHost;
try {
await new Promise((resolve, reject) => {
const onError = (e) => { server.off('listening', onListening); reject(e); };
const onListening = () => { server.off('error', onError); resolve(); };
server.once('error', onError);
server.once('listening', onListening);
server.listen(port, host);
});
// A host with IPv6 switched off refuses '::'. Fall back to IPv4 rather than not booting. Only the default is
// retried: an explicit HOST is literal (server/http/config.js bindCandidates).
let bound = null;
let lastError = null;
const candidates = bindCandidates(host);
for (let i = 0; i < candidates.length; i++) {
const candidate = candidates[i];
try {
await new Promise((resolve, reject) => {
const onError = (e) => { server.off('listening', onListening); reject(e); };
const onListening = () => { server.off('error', onError); resolve(); };
server.once('error', onError);
server.once('listening', onListening);
server.listen(port, candidate);
});
bound = candidate;
break;
} catch (e) {
lastError = e;
const retry = ['EAFNOSUPPORT', 'EADDRNOTAVAIL', 'EINVAL'].includes(e.code) && i < candidates.length - 1;
if (!retry) break;
log.warn(`[boot] cannot bind ${candidate} (${e.code}) — falling back to IPv4 only`);
}
}
if (bound === null) throw lastError;
boundHost = bound;
} catch (e) {
network.close(); // stop heartbeat/sweep timers of the half-built server
throw e;
@@ -93,7 +114,7 @@ export async function startServer(opts = {}) {
const addr = server.address();
const actualPort = typeof addr === 'object' && addr ? addr.port : port;
const url = `http://${host === '0.0.0.0' || host === '::' ? 'localhost' : host}:${actualPort}`;
const url = `http://${displayHost(boundHost)}:${actualPort}`;
let closing = null;
async function close() {
@@ -111,7 +132,7 @@ export async function startServer(opts = {}) {
return closing;
}
return { port: actualPort, host, url, server, wss, lobby, network, registry, packs, close };
return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, close };
}
// `node server/index.js` / npm start: listen, print the banner, stop on SIGINT / SIGTERM (http/boot.js).
+68 -14
View File
@@ -8,9 +8,10 @@
// Humans and bots take the lowest free seat index; seat indexes never compact.
// * ▸ Being in a LOBBY room and sending room.create / room.join implicitly leaves it. While your room is
// in a match, create/join of another room fails with ROOM_STARTED (send g.leave or room.leave first).
// * Host-only: room.setDifficulty, room.addBot, room.removeBot, room.kick, room.start. ▸ Changing the difficulty
// un-readies the other humans. ▸ room.start requires every other human to be connected and ready;
// the host's start counts as the host's ready (the host may still toggle room.ready for display).
// * Host-only: room.setDifficulty, room.setAiPicksLast, room.addBot, room.removeBot, room.kick, room.start.
// ▸ Changing the difficulty or the AI-picks-last option (co-op only) un-readies the other humans.
// ▸ room.start requires every other human to be connected and ready; the host's start counts as the host's
// ready (the host may still toggle room.ready for display).
// * room.kick {seat, playerId} (community report #17, owner approved): before the match only, the host removes another
// human like an AI seat (an AI seat stays room.removeBot's; never the host itself). `playerId` names the player the
// host confirmed: a seat that changed hands meanwhile (left, someone else joined) is refused with BAD_TARGET. The
@@ -37,10 +38,10 @@
// that window coalesce into one deferred resync, so hello spam cannot amplify into ~15 KB per request.
// * Result replay: the match's final m.public and each human's m.result are kept after the match ends. A
// human who resyncs (resume after a drop, a reloaded tab, a repeated hello) while the room is back in LOBBY
// gets room.state followed by those two frames again, until they act in the room (ready, difficulty, AI
// seats, start), leave it, or a new match starts. A human removed by the lobby grace gets them right after
// `room.closed {timeout}` on their next resume (Match.onReconnect cannot do this: the lobby drops the
// match reference at onEnd and disposes it on the next macrotask).
// gets room.state followed by those two frames again, until they act in the room (ready, difficulty, the
// AI-picks-last option, AI seats, start), leave it, or a new match starts. A human removed by the lobby grace
// gets them right after `room.closed {timeout}` on their next resume (Match.onReconnect cannot do this: the
// lobby drops the match reference at onEnd and disposes it on the next macrotask).
// * Per-network limits (internet clients only, see net.js clientAddress): at most `maxRoomsPerAddr` rooms
// created from one network may exist at once and at most `maxMatchesPerAddr` matches started from one
// network may run at once (room.create / room.start → ERR.RATE). Without them a socket loop could fill
@@ -64,6 +65,9 @@
// (or outside a room) it simply replaces the stored one; while the room's match runs it is also handed to
// match.setLoadout(playerId, loadout), which accepts it only during INFO_CHECK (the 干员调配 entry of the briefing)
// and refuses it afterwards (WRONG_PHASE: the match's loadout is locked, the stored one applies to the next match).
// ▸ Its `ops` (0.2.2: the per-operator 潜能 / 练度, shared/protocol.js checkLoadoutOps — an operator of the 干员调配
// roster or the 自选 owned pool, strict like the entries) travel with it: session.ops / seat.ops / seats[].ops and
// match.setLoadout(playerId, loadout, ops); a message without `ops` sets none (every operator 潜能 6, 精英2 Lv.60).
// * Operator ownership (干员持有, 0.2.0 补位, owner's decision 2026-10-05): room.ownership { notOwned } — the base chess
// ids the player marked as not owned — is checked leniently (shared/protocol.js checkNotOwned: anything that is not
// a droppable NORMAL chess is dropped, never the whole list; only a malformed list is BAD_MSG) and stored on the
@@ -96,7 +100,7 @@
import { randomBytes, randomInt } from 'node:crypto';
import { ERR, MAX_SEATS, MAX_SPECTATORS, ROOM_CODE_LEN, modeIdFor } from '../shared/constants.js';
import { checkLoadout, checkNotOwned, checkDiyPicks } from '../shared/protocol.js';
import { checkLoadout, checkLoadoutOps, cultivationCharIds, checkNotOwned, checkDiyPicks } from '../shared/protocol.js';
import { encode, isDroppable, isErrCode, sendRaw, sendSession } from './net.js';
import { getData as defaultGetData, lookup } from './data.js';
import { Match as DefaultMatch } from './match/Match.js';
@@ -128,6 +132,7 @@ const noopLog = { info() {}, warn() {}, error() {}, debug() {} };
/**
* @typedef {{ seat: number, playerId: string, name: string, isBot: boolean, ready: boolean,
* connected: boolean, left: boolean, loadout?: Record<string, { skill: number, module: string|null }> | null,
* ops?: Readonly<Record<string, { potential: number, cultivate: number }>> | null,
* notOwned?: readonly string[] | null, diy?: Readonly<Record<string, DiyLoadout>> | null }} Seat
* @typedef {{ charId: string, skillIndex: number, uniEquipId: string|null }} DiyLoadout
*/
@@ -139,6 +144,22 @@ function freezeLoadout(loadout) {
return Object.freeze(out);
}
/** Deep-frozen copy of checked operator settings (0.2.2 潜能 / 练度; shared by the session, the seat and the match). */
function freezeOps(ops) {
const out = {};
for (const [id, e] of Object.entries(ops || {})) out[id] = Object.freeze({ potential: e.potential, cultivate: e.cultivate });
return Object.freeze(out);
}
/** The charIds a player may set a potential / 练度 for, per data object (shared/protocol.js cultivationCharIds). */
const OPS_IDS = new WeakMap();
function opsCharIds(data) {
if (!data || typeof data !== 'object') return new Set();
let ids = OPS_IDS.get(data);
if (!ids) { ids = cultivationCharIds(data.chess, data.backups); OPS_IDS.set(data, ids); }
return ids;
}
/** Deep-frozen copy of checked 自选 picks (shared by the session, the seat and the match's PlayerState). */
function freezeDiy(picks) {
const out = {};
@@ -153,6 +174,11 @@ export class Room {
this.code = code;
this.mode = mode;
this.difficulty = difficulty;
/**
* 「AI 队友最后选择」 (room.setAiPicksLast, GitHub #338; co-op only, off by default): the match's strategy and 机变 drafts
* put every human seat before every AI seat (Match opts.aiPicksLast). Kept across the room's matches.
*/
this.aiPicksLast = false;
/** @type {string | null} */
this.hostId = null;
/** @type {(Seat | null)[]} */
@@ -202,6 +228,7 @@ export class Room {
hostId: this.hostId,
mode: this.mode,
difficulty: this.difficulty,
aiPicksLast: this.aiPicksLast,
inMatch: !!this.match,
seats: this.seats.map((s) => (s
? { seat: s.seat, playerId: s.playerId, name: s.name, isBot: s.isBot, ready: s.ready, connected: s.connected && !s.left }
@@ -311,6 +338,7 @@ export class Lobby {
case 'room.leave': return this.leave(session);
case 'room.ready': return this.ready(session, msg);
case 'room.setDifficulty': return this.setDifficulty(session, msg);
case 'room.setAiPicksLast': return this.setAiPicksLast(session, msg);
case 'room.addBot': return this.addBot(session);
case 'room.removeBot': return this.removeBot(session, msg);
case 'room.kick': return this.kick(session, msg);
@@ -501,6 +529,22 @@ export class Lobby {
return OK;
}
/** 「AI 队友最后选择」 (GitHub #338): host-only, before the match, co-op rooms only (a solo room has no AI seat). */
setAiPicksLast(session, { on }) {
const room = this.roomOf(session);
if (!room) return fail(ERR.NOT_IN_ROOM);
if (room.hostId !== session.playerId) return fail(ERR.NOT_HOST);
if (room.match) return fail(ERR.ROOM_STARTED);
if (room.mode === 'solo') return fail(ERR.BAD_TARGET, 'solo rooms have no AI teammates');
this.dropReplay(room, session.playerId);
if (room.aiPicksLast !== on) {
room.aiPicksLast = on;
for (const s of room.seats) if (s && !s.isBot && s.playerId !== room.hostId) s.ready = false;
this.broadcastState(room);
}
return OK;
}
addBot(session) {
const room = this.roomOf(session);
if (!room) return fail(ERR.NOT_IN_ROOM);
@@ -589,24 +633,30 @@ export class Lobby {
}
/**
* room.loadout (DESIGN §16): check the operator loadout against the game data, store it on the session and the seat,
* and — while a match runs — hand it to the match (accepted only during INFO_CHECK, see the header).
* room.loadout (DESIGN §16): check the operator loadout — and its per-operator 潜能 / 练度 `ops` (0.2.2) — against the
* game data, store both on the session and the seat, and — while a match runs — hand them to the match (accepted only
* during INFO_CHECK, see the header). Either part refused: nothing is stored.
*/
loadout(session, { entries }) {
loadout(session, { entries, ops }) {
const data = this.safeData();
const res = checkLoadout(entries, (id) => lookup('chess', id, data));
if (!res || res.error) return fail(res && isErrCode(res.error) ? res.error : ERR.BAD_MSG, res && res.detail);
const ids = opsCharIds(data);
const resOps = checkLoadoutOps(ops, (id) => ids.has(id));
if (!resOps || resOps.error) return fail(resOps && isErrCode(resOps.error) ? resOps.error : ERR.BAD_MSG, resOps && resOps.detail);
const loadout = freezeLoadout(res.loadout);
const opsSet = freezeOps(resOps.ops);
session.loadout = loadout;
session.ops = opsSet;
const room = this.roomOf(session);
if (!room) return OK;
const seat = room.seatOf(session.playerId);
if (seat) seat.loadout = loadout;
if (seat) { seat.loadout = loadout; seat.ops = opsSet; }
if (!room.match || !seat) return OK; // a spectator's loadout stays on its session, never reaching the match
if (typeof room.match.setLoadout !== 'function') return fail(ERR.ROOM_STARTED, 'stored for the next match');
let r;
try {
r = room.match.setLoadout(session.playerId, loadout);
r = room.match.setLoadout(session.playerId, loadout, opsSet);
} catch (e) {
this.log.error(`[lobby] ${room.code} match.setLoadout threw`, e);
return fail(ERR.INTERNAL);
@@ -668,8 +718,9 @@ export class Lobby {
if (host) host.ready = true;
const seats = room.seats.filter(Boolean).map((s) => ({
seat: s.seat, playerId: s.playerId, name: s.name, isBot: s.isBot, connected: s.connected,
// DESIGN §16: the human's checked operator loadout (bots fight with the defaults)
// DESIGN §16: the human's checked operator loadout and (0.2.2) per-operator 潜能 / 练度 (bots fight with the defaults)
loadout: s.isBot ? null : s.loadout || null,
ops: s.isBot ? null : s.ops || null,
// 0.2.0 补位: the chess the human marked as not owned (bots own every operator)
notOwned: s.isBot ? null : s.notOwned || null,
// 0.2.0 自选编队: the human's checked DIY picks (bots field no 自选 piece [ASSUMED])
@@ -685,6 +736,8 @@ export class Lobby {
mode: room.mode,
difficulty: room.difficulty,
modeId: modeIdFor(room.mode, room.difficulty),
// 「AI 队友最后选择」 (GitHub #338): fixed for the match
aiPicksLast: room.mode !== 'solo' && room.aiPicksLast === true,
seats,
// the spectator seats (header): watched like eliminated players, never players
spectators: room.spectators.map((s) => s.playerId),
@@ -932,6 +985,7 @@ export class Lobby {
return {
seat: idx, playerId: session.playerId, name: session.name, isBot: false, ready: false, connected: session.connected, left: false,
loadout: session.loadout || null,
ops: session.ops || null,
notOwned: session.notOwned || null,
diy: session.diy || null,
};
+16 -5
View File
@@ -12,14 +12,21 @@
// opts.mode 'solo' | 'coop'
// opts.difficulty 'FUNNY'|'NORMAL'|'HARD'|'ABYSS'
// opts.modeId string modeIdFor(mode, difficulty), e.g. 'mode_multi_hard'
// opts.aiPicksLast boolean (optional) the co-op room option 「AI 队友最后选择」 (room.setAiPicksLast, GitHub #338):
// the strategy and 机变 drafts put every human seat before every AI seat
// (MatchPhases.humansFirst); only `true` turns it on; ignored in solo
// opts.seats Array<{ seat: 0..3, playerId: string, name: string, isBot: boolean, connected: boolean,
// loadout?: { [baseChessId]: { skill: index, module: uniEquipId|'none'|null } } | null,
// ops?: { [charId]: { potential: 1–6, cultivate: 0–3 } } | null,
// notOwned?: string[] | null,
// diy?: { [slotBaseId]: { charId, skillIndex, uniEquipId } } | null }>
// sorted by seat, 1–4 entries, ≥ 1 human; solo ⇒ exactly 1 human and no bots.
// Bot playerIds start with 'ai_'. Seat indexes may have gaps (e.g. seats 0 and 2).
// `loadout` (DESIGN §16, optional): the human's operator loadout, already checked by the lobby
// (shared/protocol.js checkLoadout); PlayerState re-checks it against opts.data and ignores it for bots.
// `ops` (0.2.2, optional): the human's per-operator 潜能 / 练度 (checkLoadoutOps; missing = 潜能 6, 精英2
// Lv.60 — the owner's decision of 2026-10-08), re-checked and changed like the loadout (INFO_CHECK);
// bots fight with the defaults.
// `notOwned` (0.2.0 补位, optional): the base chess ids the human marked as not owned (干员持有) — fixed
// for the match; those chess fight as their stand-ins (PlayerState setNotOwned; bots own everything).
// `diy` (0.2.0 自选编队, optional): the human's 自选 picks (shared/protocol.js checkDiyPicks) — fixed for
@@ -53,9 +60,10 @@
// (a resync request). May be called without a preceding onDisconnect. Resend full
// state: m.public, m.private and, if a battle is on (client-side combat), the
// b.start of the field the player is on / watching (server-run mode: m.field + b.snap).
// setLoadout(playerId, loadout) → { ok } | { error, detail? } (DESIGN §16; optional for the platform) a new checked
// operator loadout from room.loadout. Accepted only during INFO_CHECK (the briefing's
// 干员调配 entry); afterwards the match's loadout is locked (WRONG_PHASE).
// setLoadout(playerId, loadout, ops?) → { ok } | { error, detail? } (DESIGN §16; optional for the platform) a new
// checked operator loadout (and its 潜能 / 练度 `ops`, 0.2.2) from room.loadout. Accepted only
// during INFO_CHECK (the briefing's 干员调配 entry); afterwards the match's loadout is locked
// (WRONG_PHASE).
// onLeave(playerId) The human quit permanently (g.leave, room.leave, or the 10-minute reconnect
// window expired). They will never return under this playerId in this match;
// treat as quit (AI takes over / eliminated per DESIGN). No onDisconnect follows.
@@ -106,7 +114,7 @@
// (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
// ('sample': ~1 in 8, in later slices, mismatches logged; 'all': before accepting — the
// server's result wins on a mismatch)
//
// Engine-only extra options (tests / tools; the lobby never passes them):
@@ -121,7 +129,7 @@
// opts.botSliceMs wall-clock ms of rehearsal per scheduler callback (default 8 with a real scheduler, unbounded
// with a virtual one); the rest runs in later callbacks (scheduleBotPrep)
// opts.headlessSliceMs wall-clock ms per callback of a server-run normal / 联防 field (client-side combat: bots,
// takeovers; default 8 with a real scheduler, at once with a virtual one)
// takeovers, result verification; default 8 with a real scheduler, at once with a virtual one)
// Seats may be all bots (tools/matchrun.mjs); the lobby always has ≥ 1 human.
//
// Diagnostics: m.errors / m.errorCount (engine), m.dispatcher.errors / .errorsByKey (meta handlers), m.simErrors
@@ -151,6 +159,7 @@
// intents.js the g.* / b.* router, emotes, AI 托管, g.unitStats
// pause.js the solo pause
// phases.js INFO_CHECK, BAND_DRAFT (the strategy draft), BATTLE_CHECK, ROUND_START
// setupVote.js unanimous INFO_CHECK setup reroll, suspended confirmation, preserved player settings
// spDraft.js SP_DRAFT (机变) and the rolls the meta effects ask for (bounties, item ids, pools)
// prep.js PREP: its start, the AI seats' sliced preps, Ready, the deadline, its end
// combat.js battle construction, COMBAT (both modes), the per-field results, then 联防 or SETTLE
@@ -237,6 +246,8 @@ export class Match {
this.gd = new GameData(this.data, this.modeId);
if (!this.difficulty) this.difficulty = this.gd.difficulty;
this.isSolo = this.mode === 'solo' || this.gd.isSolo;
/** 「AI 队友最后选择」 (opts.aiPicksLast, GitHub #338): humans draft before AI seats (MatchPhases.humansFirst) */
this.aiPicksLast = !this.isSolo && opts.aiPicksLast === true;
this.ownsScheduler = !opts.scheduler;
this.sched = opts.scheduler || new RealScheduler({ now: opts.now || Date.now, onError: (e) => this.reportError('timer', e) });
this.registry = opts.registry || getDefaultRegistry();
+16 -1
View File
@@ -73,6 +73,12 @@
// 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`.
// * 潜能 / 练度 (0.2.2, the owner's decision of 2026-10-08 「调配干员里自己设置吧,默认满潜满加成」): the human's checked
// `seat.ops` ({ [charId]: { potential, cultivate } }, defaults dropped) is re-checked (checkLoadoutOps) and changed
// with the loadout; a missing entry — and every operator of a bot — is 潜能 6, 精英2 Lv.60. battleInput() states both
// for every operator the player owns (a chess fielded as itself, an owned 自选 pick: `cultivationFor`), never for a
// 补位 stand-in or a prototype pick (shared/potential.js cultivationOf); the 自选 summons' hand count follows the
// owner's potential (望's 棋子). m.private exposes `ops`; the scouting views carry each unit's `potential` / `cultivate`.
// * Operator ownership (0.2.0 补位, the approved plan — owner's decision 2026-10-05): the human's `seat.notOwned` (base
// chess ids marked 未持有 on the 干员持有 screen) is re-checked against this match's data (shared/protocol.js
// checkNotOwned + a stand-in record in data/backups.json) and fixed for the match (`standIns`; bots own every
@@ -152,7 +158,9 @@ export class PlayerState {
this.lastEmoteAt = -Infinity;
/** operator loadout (DESIGN §16): frozen { [baseChessId]: { skill, module } }, {} = every chess on its defaults */
this.loadout = Object.freeze({});
if (!this.isBot && seat.loadout) this.setLoadout(seat.loadout);
// 0.2.2: the per-operator 潜能 / 练度 ({} = every operator 潜能 6, 精英2 Lv.60 — bots always)
this.ops = Object.freeze({});
if (!this.isBot && (seat.loadout || seat.ops)) this.setLoadout(seat.loadout ?? null, seat.ops ?? null);
/**
* 补位 (0.2.0): the base chess ids this player fields as their stand-ins — the seat's not-owned list when the match
* started, re-checked against this match's data; frozen, sorted; [] = every operator owned (bots always)
@@ -201,6 +209,13 @@ export class PlayerState {
this.effects = [];
/** active bounties: { id, card, roundsLeft, chooser } */
this.bounties = [];
/**
* The open personal choice of the Art 教鞭 (Match.offerBountyChoice): up to three cards.bounty entries the owner picks one
* of with `g.choice { idx, choiceId }` — m.private only; Ready is refused while it is open, the prep's deadline (or the
* bot) resolves it
* @type {null | { id: string, round: number, sourceItemId: string, cards: any[] }}
*/
this.personalChoice = null;
/** free-form counters for content (ctx.counter / setCounter) */
this.counters = {};
/** per-round counters (reset at round start) */
+1 -1
View File
@@ -229,7 +229,7 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
if (f0 - ps.funds !== price && !fx && !hasSpendEffects(m, ps)) fail(`${ps.playerId}: level-up paid ${f0 - ps.funds}, price ${price}`);
const next = gd.upgradeBase(ps.shop.level) ?? 0;
if (ps.shop.upgradePrice !== next) fail(`${ps.playerId}: upgrade price after level-up ${ps.shop.upgradePrice}, expected ${next}`);
// the new level's extra slots open at once (item 19 of 2026-10-06); the cards shown before stay in place
// the new level's extra slots open at once, empty (GitHub #332 / PR #333); the cards shown before stay in place
const { chess, item } = gd.shopSlots(ps.shop.level);
const want = Math.max(chess, layout0.chess) + Math.max(item, layout0.item);
if (ps.shop.slots.length !== want) fail(`${ps.playerId}: ${ps.shop.slots.length} shop slots after the level-up to ${ps.shop.level}, expected ${want}`);
+19 -5
View File
@@ -37,7 +37,8 @@
// tiles, where it blocks — except one whose trait reads 「可以放置于远程位」 (歌蕾蒂娅, 崖心, 见行者, any module:
// placeClass 'all', shared/highGround.js), which takes a 高台 whose range covers the enemy road, else the ground
// (owner 2026-10-04 for the 高台 preference, 2026-10-05 for who may use one). It stays a blocker in the lineup
// (basePositionClass). Each unit's range grid — the one it is
// (basePositionClass). A blocker whose range grid is its own tile only (range 0-1: 角峰, 古米, 泡泡, 折桠, 菲莱, 蛇屠箱,
// 塞雷娅, 余) is planned on a free tile of the enemy road first: off it nothing is blocked or hit. Each unit's range grid — the one it is
// deployed with, rangeRec (loadoutRecord attackRangeGrid) — is rotated per direction (DESIGN §3; RIGHT is tried first
// and kept on ties, so symmetric ranges and melee units whose front adds nothing stay facing the gates), so
// ranged units turn toward the enemy path tiles they cover best and blockers toward the road; on 气流 tiles
@@ -978,6 +979,12 @@ export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupi
const preferHigh = cls === 'all' && basePositionClass(r0) === 'melee';
let bestHigh = null;
let bestHighV = -Infinity;
// a blocker whose range is its own tile only (range 0-1: 角峰, 古米, 泡泡, 折桠, 菲莱, 蛇屠箱, 塞雷娅, 余) neither blocks nor
// hits anything off the enemy road — the official range "0-1" (excel range_table) is the one grid (0,0) — so a free road tile
// comes first (the kill estimate saturates in a full layout and then cannot tell the tiles apart)
const preferRoad = isBlocker(r0) && r0.rangeGrid?.length === 1 && r0.rangeGrid[0].every((v) => v === 0);
let bestRoad = null;
let bestRoadV = -Infinity;
for (const [r, c] of legalTiles(map, cls)) {
const k = tileKey(r, c);
if (taken.has(k) || (within && !within.has(k)) || (without && without.has(k))) continue;
@@ -993,13 +1000,15 @@ export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupi
const v = layout.value() + noise;
layout.units.pop();
if (v > bestV) { bestV = v; best = [k, r, c, dir]; }
// (the best road tile by the same value)
if (preferRoad && model.ground.has(k) && v > bestRoadV) { bestRoadV = v; bestRoad = [k, r, c, dir]; }
if (preferHigh && map.get(k) === 'ranged' && [...u.cover].some((ck) => model.ground.has(ck)) && v > bestHighV) {
bestHighV = v;
bestHigh = [k, r, c, dir];
}
}
}
const pick = bestHigh || best;
const pick = bestHigh || bestRoad || best;
if (!pick) continue;
taken.add(pick[0]);
layout.units.push(unitOf(r0, pick[0], pick[1], pick[2], pick[3], model));
@@ -1329,6 +1338,7 @@ export function botPrepBegin(m, ps) {
*/
export function* botPrepBeginSteps(m, ps) {
if (!ps.alive || ps.ready) return null;
m.autoPickPersonalChoice(ps, 'bot');
// 1. reward offers (free)
takeOffers(m, ps);
yield;
@@ -1366,6 +1376,7 @@ export function* botPrepEndSteps(m, ps, job = null) {
if (freeSlot(ps.hand) < 0) freeHandSlot(m, ps);
// what the next prep's bench shed may judge by value: what is owned now (later gains wait for a placement step)
rememberOwned(ps);
m.autoPickPersonalChoice(ps, 'bot');
tryDo(() => ps.setReady(true));
}
@@ -1807,8 +1818,8 @@ const isBountyArt = (gd, itemId) => { const rec = gd.item(itemId); return !!rec
/**
* Use an Art: 画卷 copies the operator on its tile (its range is the tile + the one in front) — the most valuable
* deployed operator, with a free hand slot for the copy; 教鞭 / “神秘顾客” add a bounty to the next battle (one the engine
* draws, not chosen) — only after a perfect battle with LP to spare, else the Art stays in the hand for a later round
* deployed operator, with a free hand slot for the copy; 教鞭 offers a scored personal bounty choice, “神秘顾客” adds a
* random bounty — only after a perfect battle with LP to spare, else the Art stays in the hand for a later round
* (destroying 教鞭 gives nothing; a full hand at the prep end: freeHandSlot).
*/
function useArt(m, ps, item, ctx) {
@@ -1825,7 +1836,10 @@ function useArt(m, ps, item, ctx) {
if (keys.includes('trap_create_self_choice')) {
if (!lastPerfect(m, ps) || ps.lp < 10) return; // kept
const [key] = [...ps.board.keys()];
if (key) { const [r, c] = parseKey(key); tryDo(() => ps.useArt(item.uid, r, c)); }
if (key) {
const [r, c] = parseKey(key);
if (tryDo(() => ps.useArt(item.uid, r, c))) m.autoPickPersonalChoice(ps, 'bot');
}
return;
}
const [key] = [...ps.board.keys()];
+20 -5
View File
@@ -25,11 +25,11 @@
// operator gained into the hand
// trap_copy_front_char 画卷 (Art) copy the chess on the tile / in front
// trap_create_self_choice {choice_event} 教鞭 / 神秘顾客 (Art) add a random bounty to your next battle
// [ASSUMED simplification, documented in docs/META.md: 教鞭/神秘顾客 pick the bounty for the player instead of opening
// a personal choice overlay.]
// This builtin picks a random bounty by default; content/items/meta.js overrides Pointing Stick with a private choice.
import { getData } from '../data.js';
import { itemKey } from './gamedata.js';
import { msg, dn } from '../../shared/i18n.js';
const int = (v, d = 0) => (Number.isFinite(v) ? Math.trunc(v) : d);
@@ -39,6 +39,15 @@ const paramsOf = (ctx, item) => {
return rec && rec.params && typeof rec.params === 'object' ? rec.params : {};
};
/**
* The item gave nothing — the copies are all owned (the shared pool, research 06 §7) or no operator shares the bond — but
* it is still destroyed ("装备时销毁"): say why instead of swallowing it (GitHub #401, the owner's OK of 2026-10-09).
*/
function toastNothing(ctx, ev, chessName = null) {
const who = ctx.gd.item(ev.item?.id)?.name || '';
ctx.toast(chessName ? msg('{who}:卡池中已没有{name}', { who: dn(who), name: dn(chessName) }) : msg('{who}:没有可获得的同盟约干员', { who: dn(who) }), 'warn');
}
/** random chess sharing at least one bond with `bonds`, tier ≤ maxTier */
function rollSameBond(ctx, bonds, maxTier, exclude = null) {
const set = new Set(bonds);
@@ -101,10 +110,12 @@ const ITEM_HANDLERS = {
onEquip(ctx, ev) {
const n = Math.max(1, int(paramsOf(ctx, ev.item).count, 1));
const bonds = ctx.pieceBonds(ev.target.uid);
let got = 0;
for (let k = 0; k < n; k++) {
const id = rollSameBond(ctx, bonds, ctx.shopLevel());
if (id) ctx.grantChess(id);
if (id && ctx.grantChess(id)) got++;
}
if (!got) toastNothing(ctx, ev);
},
},
use_equip_gain_coin_when_next_round_start: {
@@ -139,9 +150,12 @@ const ITEM_HANDLERS = {
onEquip(ctx, ev) {
const base = ctx.gd.baseIdOf(ev.target.id);
const owned = [...ctx.board(), ...ctx.hand(), ...ctx.temp()].filter((p) => p && p.kind === 'chess' && !p.golden && ctx.gd.baseIdOf(p.id) === base).length;
if (owned >= 2) { ctx.grantChess(base); return; }
if (owned >= 2) {
if (!ctx.grantChess(base)) toastNothing(ctx, ev, ctx.gd.chess(base)?.name || null);
return;
}
const id = rollSameBond(ctx, ctx.pieceBonds(ev.target.uid), 6);
if (id) ctx.grantChess(id);
if (!id || !ctx.grantChess(id)) toastNothing(ctx, ev);
},
},
use_equip_reward_special_goods_char_chess: {
@@ -152,6 +166,7 @@ const ITEM_HANDLERS = {
// a pick-one offer never shows one operator twice (user playtest #6 item 19); fewer cards when the pool runs out
for (let k = 0; k < n; k++) { const id = rollSameBond(ctx, bonds, ctx.shopLevel(), ids); if (id) ids.push(id); }
if (ids.length) ctx.offerChess(ids, { source: 'item' });
else toastNothing(ctx, ev);
},
},
// 信标 (act2autochess eff_acarm109 / eff_acgarm109 "装备时,目标干员和本装备销毁并进行一次特殊刷新,出现两名与携带者同等阶的
+1
View File
@@ -674,6 +674,7 @@ export function makeCtx(m, ps, source, hook, ev = null) {
// ---- bounties / choices
addBounty: (card) => m.addBounty(ps, card),
offerBountyChoice: (cards, sourceItemId) => m.offerBountyChoice(ps, cards, sourceItemId),
// ---- messaging
// a string or a shared/i18n.js msg(msgid, params)
+52 -18
View File
@@ -17,9 +17,11 @@
// Client-side combat (DESIGN §14) helpers:
// HeadlessJob / runHeadless(battle) step a battle to its end — in wall-clock-bounded slices (bots, takeovers
// under a real scheduler) or at once (verification, virtual time) — and keep a
// progress timeline [[gt, killed, total]] for the teammates' waiting UI
// (联防: [gt, killed, total, left] — left = spec.js uniteLeft, the leakers'
// enemies still standing, for their live counter; user playtest #6 item 7)
// progress timeline [[gt, killed, total, resolved]] for the teammates' waiting UI
// (联防: a 5th element — left = spec.js uniteLeft, the leakers'
// enemies still standing, for their live counter; user playtest #6 item 7).
// `resolved` = the HUD capsule's numerator (Battle.resolved), sampled on the
// field clock: a server-run / bot field has no authority reporting b.progress
// HeadlessPacer real-time pacing of a dynamic set of server-run battles without snapshots
// (boss fields that share the pool while other fields run on clients); a
// takeover's fast-forward is spread over the pacing intervals
@@ -56,7 +58,7 @@ export function snapFrame(fieldId, snap) {
/** Synthetic per-player result used when a field could not run at all (never punishes the player). */
export function emptyPerPlayer() {
return { killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] };
return { killed: 0, total: 0, resolved: 0, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] };
}
/** A finished stand-in for a battle that failed to construct. */
@@ -72,13 +74,13 @@ export class DeadBattle {
this.errorCount = 1;
const perPlayer = {};
for (const p of opts.players || []) perPlayer[p.playerId] = emptyPerPlayer();
this._result = { time: 0, reason, perPlayer, killed: 0, total: 0, errors: 1, synthetic: true };
this._result = { time: 0, reason, perPlayer, killed: 0, total: 0, resolved: 0, errors: 1, synthetic: true };
this.errors = [];
}
step() {}
forceEnd() {}
result() { return this._result; }
snapshot() { return { fieldId: this.fieldId, t: 0, units: [], dp: 0, killed: 0, total: 0 }; }
snapshot() { return { fieldId: this.fieldId, t: 0, units: [], dp: 0, killed: 0, total: 0, resolved: 0 }; }
drainEvents() { return []; }
fieldMeta() { return { fieldId: this.fieldId, kind: this.kind, rect: this.rect, stageId: this.stageId, units: [] }; }
on() { return null; }
@@ -238,7 +240,7 @@ export class FieldRunner {
}
const perPlayer = {};
for (const pid of f.players) perPlayer[pid] = emptyPerPlayer();
return { time: 0, reason: 'forced', perPlayer, killed: 0, total: 0, errors: 1, synthetic: true };
return { time: 0, reason: 'forced', perPlayer, killed: 0, total: 0, resolved: 0, errors: 1, synthetic: true };
}
}
@@ -259,11 +261,15 @@ export const CATCHUP_TICKS_PER_INTERVAL = 240;
const perfNow = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
/**
* A timeline sample of a battle: [gt, killed, total], plus — 联防 — the leakers' enemies still standing
* (spec.js uniteLeft; omitted when unknown).
* A timeline sample of a battle: [gt, killed, total, resolved], plus — 联防 — the leakers' enemies still standing at
* index 4 (spec.js uniteLeft; omitted when unknown). `resolved` is the HUD capsule's numerator of that game second
* (Battle.resolved: the field's own scheduled enemies knocked out or leaked) — the server-run / bot fields have no
* authority that reports b.progress, so the teammates' capsule reads it from here, never from `progress.leaks`.
*/
export function timelineSample(b) {
const s = [Number(b && b.time) || 0, Number(b && b.killed) || 0, Number(b && b.total) || 0];
const total = Number(b && b.total) || 0;
const resolved = Number.isFinite(b && b.resolved) ? Math.max(0, Math.min(total, b.resolved)) : null;
const s = [Number(b && b.time) || 0, Number(b && b.killed) || 0, total, resolved];
if (b && b.kind === 'unite') {
let left = null;
try { left = uniteLeft(b); } catch { left = null; }
@@ -276,7 +282,7 @@ export function timelineSample(b) {
* A server-run battle stepped to its end, in one go or in wall-clock-bounded slices (a low-power host must not stall
* its event loop for the ~0.1–1 s a whole battle takes, several times at once when bots fight): `run(budgetMs)` steps
* until the battle ends (→ true) or the budget is used (→ false; checked every 16 ticks). `timeline` grows while it
* runs (timelineSample: [gt, killed, total(, left)] every TIMELINE_EVERY game seconds, then the final state);
* runs (timelineSample: [gt, killed, total, resolved(, left)] every TIMELINE_EVERY game seconds, then the final state);
* `output()` once done gives `{ battle, result, timeline, crashed }`. A throwing battle is force-ended, then replaced by
* a DeadBattle; the run is bounded by HARD_CAP_SECONDS.
*/
@@ -345,9 +351,9 @@ export function runHeadless(battle, opts = {}) {
return job.output();
}
/** Timeline sample at game time `gt`: [gt, killed, total(, left)] of the last sample ≤ gt. */
/** Timeline sample at game time `gt`: [gt, killed, total, resolved(, left)] of the last sample ≤ gt. */
export function timelineAt(timeline, gt) {
if (!Array.isArray(timeline) || !timeline.length) return [0, 0, 0];
if (!Array.isArray(timeline) || !timeline.length) return [0, 0, 0, null];
let lo = 0, hi = timeline.length - 1;
if (gt >= timeline[hi][0]) return timeline[hi];
while (lo < hi) {
@@ -734,7 +740,9 @@ const sameMods = (a, b) => {
/**
* Semantic validation of a client's BattleResult against the battle's spec (DESIGN §14 "Result validation"). The
* payload already passed shared/protocol.js isBattleResult (types, sizes). Checks: every spec player reported and
* nobody else; killed ≤ total ≤ bound; each leaked enemy key exists in the spawns (multiset bound; keys content may
* nobody else; killed ≤ bound and total ≤ bound (maxTotal: `killed` counts every counted knock-out — runtime splits
* and summons included — and may exceed `total`, which counts only the round's own scheduled enemies; PR #157 capsule
* semantics); each leaked enemy key exists in the spawns (multiset bound; keys content may
* spawn are bounded by the total) — boss fields excepted (their leaks cost team LP through b.progress); a leak is
* `counted: false` only for enemies that never count (data notCountInTotal, countInTotal false, boss / part entries,
* content spawns); 联防: leaks + never-spawned re-entries per (enemy, leaker) ≤ what that leaker sent in, and a split /
@@ -742,7 +750,12 @@ const sameMods = (a, b) => {
* gains ≤ 60 + 4·round + what the player's layer 特质 can add to that bond (layerAllowanceOf: their per-battle caps, no
* flat bound when one is uncapped) and ≤ the room left under BOND_LAYER_CAP (999) from the bond's starting layers, only
* on bonds the player's lineup / band / effects / items name, none when the spec disables gains; coins ≤ the spawns' bounty coins;
* perfect consistent with the counted leaks; unit states only for the player's own units, within range.
* perfect consistent with the counted leaks; unit states only for the player's own units, within range. `resolved` (the
* HUD capsule's numerator): the result's own — the FIELD's, what the client's capsule showed — is kept as reported,
* clamped to the field's `total`, and is what m.public publishes (never the sum of the players': an enemy that crosses
* the midline of a two-half field is billed to one player's `total` and to the other's leak, so their own numbers
* clamp it to 0); the players' own (each clamped to their `total`) are the fallback of a result with none; absent when
* neither is complete.
* @returns {{ ok: true, result: object } | { ok: false, reason: string }}
*/
export function validateClientResult(spec, raw, { gd = null } = {}) {
@@ -781,7 +794,10 @@ export function validateClientResult(spec, raw, { gd = null } = {}) {
const p = raw.perPlayer[pid];
if (!p || typeof p !== 'object') return bad('player');
const own = B.players.get(pid);
if (!Number.isInteger(p.killed) || !Number.isInteger(p.total) || p.killed < 0 || p.killed > p.total || p.total > B.maxTotal) return bad('counts');
// `killed` counts every counted knock-out — a runtime split child / summon too — while `total` counts only the
// enemies the round scheduled, so `killed ≤ total` no longer holds (PR #157 capsule semantics): bound it by
// maxTotal (the spec's content-spawn allowance) instead, exactly like `total`
if (!Number.isInteger(p.killed) || !Number.isInteger(p.total) || p.killed < 0 || p.killed > B.maxTotal || p.total > B.maxTotal) return bad('counts');
const leaked = [];
for (const l of Array.isArray(p.leaked) ? p.leaked : []) {
if (!l || typeof l.enemyKey !== 'string') return bad('leak');
@@ -820,7 +836,9 @@ export function validateClientResult(spec, raw, { gd = null } = {}) {
leaked.push(e);
}
const countedLeaks = leaked.filter((l) => l.counted !== false).length;
if (!bossLike && countedLeaks > p.total + B.spawnCount) return bad('leaks > total');
// `total` is the round's own scheduled enemies; the leaks may include content-spawned children (a split / summon),
// which the spec bounds by maxTotal (the same allowance `killed` is checked against above)
if (!bossLike && countedLeaks > p.total + B.maxTotal) return bad('leaks > total');
if (typeof p.perfect !== 'boolean' || p.perfect !== (countedLeaks === 0)) return bad('perfect');
const layerGains = {};
for (const [bondId, n] of Object.entries(p.layerGains || {})) {
@@ -868,10 +886,26 @@ export function validateClientResult(spec, raw, { gd = null } = {}) {
damageDealt: stat(p.damageDealt), bossDamage: stat(p.bossDamage), healingDone: stat(p.healingDone), deaths: Math.trunc(stat(p.deaths)),
unitsEnd, unitStats,
};
// the HUD capsule's numerator: kept only when the client reported one (an absent value must stay absent — the
// teammate UI then falls back to `killed`, never to a fabricated 0); clamped to the denominator
if (Number.isInteger(p.resolved) && p.resolved >= 0) perPlayer[pid].resolved = Math.min(p.total, p.resolved);
}
if (coinsSum > B.bountyCoins + 1e-6) return bad('coins');
const result = { time: raw.time, reason: raw.reason, perPlayer, killed: 0, total: 0, errors: Math.max(0, Math.trunc(Number(raw.errors) || 0)) };
for (const pid of pids) { result.killed += perPlayer[pid].killed; result.total += perPlayer[pid].total; }
let ownResolved = 0, ownKnown = pids.length > 0;
for (const pid of pids) {
result.killed += perPlayer[pid].killed;
result.total += perPlayer[pid].total;
if (Number.isInteger(perPlayer[pid].resolved)) ownResolved += perPlayer[pid].resolved; else ownKnown = false;
}
// The FIELD's capsule numerator (Battle.resolved, what the client's own capsule showed), kept as reported and clamped
// to the field's total — NOT the sum of the players' own numbers: an enemy that spawns on one half and leaks on the
// other (the 联防 lane, the boss pair's crossing routes) is billed to one player's `total` and to the other's
// `leakedInTotal`, so each player's own min(total, …) clamps it away (0 and 0 for an enemy the field resolved). The sum
// is only the fallback of a result with no field-level number; none of either stays absent (the teammate UI then
// falls back to `killed`, never to a fabricated 0).
if (Number.isInteger(raw.resolved) && raw.resolved >= 0) result.resolved = Math.min(result.total, raw.resolved);
else if (ownKnown) result.resolved = Math.min(result.total, ownResolved);
if (spec.kind === 'unite' && Array.isArray(raw.unspawned)) {
const unspawned = [];
for (const u of raw.unspawned) {
+8
View File
@@ -18,6 +18,8 @@
// bonds ps.bonds equals a fresh computeBonds() (every mutation recomputed them); every bond's layers 0 … BOND_LAYER_CAP
// shop slot count follows the rolled layout; ids known; banned chess never offered by the shop / rewards
// elim. an eliminated player owns nothing (board, hand, temp, shop, offers, bounties, funds)
// choice a player's open 教鞭 choice (`personalChoice`) is one of an alive, not-Ready player in the PREP of its own round,
// with one to three different cards
// match phase known; teamLp / boss pool within range; combat fields match the alive players
import { PHASE, BOND_LAYER_CAP } from '../../shared/constants.js';
@@ -75,6 +77,12 @@ export function collectViolations(m, { limit = 25 } = {}) {
if (ps.shop.slots.length || ps.offers.length || ps.bounties.length) fail(`${id}: eliminated but keeps shop/offers/bounties`);
if (ps.funds || ps.pendingFunds) fail(`${id}: eliminated with funds ${ps.funds}+${ps.pendingFunds}`);
}
// 教鞭's personal choice (Match.offerBountyChoice): resolved before the prep ends — by its owner, the deadline or the bot
const pc = ps.personalChoice;
if (pc) {
if (!ps.alive || ps.ready || m.phase !== PHASE.PREP || pc.round !== m.round) fail(`${id}: a personal choice outside its own prep (${m.phase} R${m.round}, offered in R${pc.round})`);
if (!Array.isArray(pc.cards) || !pc.cards.length || pc.cards.length > 3 || new Set(pc.cards.map((c) => c && c.effectId)).size !== pc.cards.length) fail(`${id}: personal choice of ${pc.cards && pc.cards.length} cards`);
}
// pieces
const all = [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)];
+48 -8
View File
@@ -231,6 +231,35 @@ export class MatchBoss {
if (credit > 0) this._teamLpLoss(credit);
}
/**
* The authority's per-player split of the LP its field's enemy leaks cost (b.progress `leaksBy`: cumulative, per player,
* the leader's own LP effects not in it) — the highest value seen per player. The field's perfect-payout bounties are
* paid only where the final result agrees with it (`_bossLeaksAgree`).
*/
_noteLeaksBy(f, by) {
if (!by || typeof by !== 'object') return;
if (!f.leaksBy) f.leaksBy = {};
for (const pid of f.players) {
const v = Number(by[pid]);
if (Number.isFinite(v) && v > (f.leaksBy[pid] || 0)) f.leaksBy[pid] = v;
}
}
/**
* Whether a boss field's b.result may be believed about one player's leaks before a perfect-payout bounty pays: the
* result comes from a client (a server run is the truth itself) and does not contradict what that client reported while
* it fought — LP it asked the team to pay must have been split into the players' own leaks and the leader's effects
* (b.progress `leaksBy`; reports without the split explain nothing), and the player's leaked list must show at least the
* leak LP reported for it (a leak is never moved to the other seat or dropped). A modified authority can still lie in
* both; what the server cannot see it cannot check (the boss path never re-simulates).
*/
_bossLeaksAgree(f, res, pid) {
if (!f.cc || f.resultSource !== 'client') return true;
if (f.lpReported > 1e-9 && !f.leaksBy) return false;
const shown = ((res.perPlayer[pid] && res.perPlayer[pid].leaked) || []).reduce((n, l) => n + (Number.isFinite(l.lpr) && l.lpr >= 0 ? l.lpr : 1), 0);
return shown + 1e-6 >= ((f.leaksBy && f.leaksBy[pid]) || 0);
}
/** The server runs a boss field in real time (no client left): credits only what exceeds the client's reports. */
_bossServerRun(f) {
const credit = new CreditPool(this.bossPool, { acked: f.bossAcked, ackedBy: f.bossBy });
@@ -372,11 +401,15 @@ export class MatchBoss {
}
/**
* Bounties after a boss field: kill-bounty coins go to pending funds (spent in the Hidden Core's prep) and every
* bounty used one of its battles, exactly like SETTLE does for normal rounds.
* Bounties after a boss field: kill-bounty coins and — when `perfect` — the coins of every perfect-payout bounty (a
* 战术特训 card: PRTS "若各自行动阶段就达成完美作战,获得N资金", the same sum SETTLE pays after a normal battle) go to
* pending funds (spent in the Hidden Core's prep), and every bounty used one of its battles, exactly like SETTLE does
* for normal rounds.
* @param {boolean} perfect the player's own field was perfect and the team won (`_finishFinal`)
*/
_settleBossBounties(ps, pp) {
const coins = Math.max(0, Math.trunc(Number(pp.coins) || 0));
_settleBossBounties(ps, pp, perfect) {
let coins = Math.max(0, Math.trunc(Number(pp.coins) || 0));
if (perfect) for (const b of ps.bounties) if (b.card.payout === 'perfect') coins += b.card.coin;
if (coins > 0) { ps.pendingFunds += coins; ps.stats.fundsGained += coins; }
if (!ps.bounties.length) return;
for (const b of ps.bounties) b.roundsLeft--;
@@ -471,6 +504,9 @@ export class MatchBoss {
_finishFinal(hidden, resultOf) {
if (this.phase !== (hidden ? PHASE.HIDDEN_CORE : PHASE.FINAL_ASSAULT)) return;
this._stopClientCombat();
// the end condition the server registered first decides (client-side combat: _endFinal — pool 0 → victory, team LP 0
// → defeat); a boss field's final result may never turn a defeat into a victory (user playtest #6 item 5)
const victory = this._finalEnding ? this._finalEnding === 'cleared' : this.bossPool.hp <= 0;
for (const f of this.fields) {
const res = resultOf(f);
this._collectSimErrors(f, res);
@@ -485,13 +521,17 @@ export class MatchBoss {
ps.dirty(); // m.private.stats
this._charDamageTickers(ps, pp);
}
if (pp && ps) this._settleBossBounties(ps, pp);
if (pp && ps) {
// the own battle counts as perfect when the team won, the result is a real one (not the stand-in of a field that
// never reported) and the player's own field let no counted enemy through [ASSUMED: the boss battle is a battle
// of the player's own — "下场作战" — so a perfect-payout card pays as after a normal one]
const perfect = victory && !res.synthetic && pp.perfect === true && !(pp.leaked || []).some((l) => l && l.counted !== false)
&& this._bossLeaksAgree(f, res, pid);
this._settleBossBounties(ps, pp, perfect);
}
if (pp && ps) this.dispatch(ps, 'onBattleResult', { result: pp, lpLoss: 0, perfect: !!pp.perfect, boss: true });
}
}
// the end condition the server registered first decides (client-side combat: _endFinal — pool 0 → victory, team LP 0
// → defeat); a boss field's final result may never turn a defeat into a victory (user playtest #6 item 5)
const victory = this._finalEnding ? this._finalEnding === 'cleared' : this.bossPool.hp <= 0;
this._syncTeamLp();
this.deadline = 0;
this.overtimeAt = 0;
+15 -7
View File
@@ -24,8 +24,9 @@ export class MatchClientCombat {
_clearFieldTimers(f) {
if (!f || !f.cc) return;
for (const k of ['deadlineTimer', 'doneTimer', 'waitTimer', 'sliceTimer']) if (f[k]) { this.cancel(f[k]); f[k] = null; }
for (const k of ['deadlineTimer', 'doneTimer', 'waitTimer', 'sliceTimer', 'verifyTimer']) if (f[k]) { this.cancel(f[k]); f[k] = null; }
f.job = null;
f.verifyJob = null;
}
/** A client-combat field record: the JSON BattleSpec of its Battle options plus the authority / result state. */
@@ -35,16 +36,22 @@ export class MatchClientCombat {
const battleId = seq.length + 1 + String(fieldId).length <= 64 ? `${seq}.${fieldId}` : seq;
const spec = buildBattleSpec({ ...opts, battleId, fieldId, kind, content: this.battleContent, boss });
let total = 0;
for (const x of spec.spawns) if (x && x.tag !== 'boss' && x.tag !== 'part') total += Math.max(1, Math.floor(Number(x.count) || 1));
for (const x of spec.spawns) if (x && x.tag !== 'boss' && x.tag !== 'part' && x.countInTotal !== false) total += Math.max(1, Math.floor(Number(x.count) || 1));
return {
cc: true, fieldId, kind, players: players.slice(), battleId, spec, battle: null, live: true, done: false,
mode: null, authority: null, startAt: this.sched.now(), result: null, resultSource: null, timeline: null, endGt: null,
progress: { gt: 0, killed: 0, total, leaks: 0, done: false }, lastProgressAt: this.sched.now(),
// progress: the field's own numbers as far as they are known before its first b.progress / result (`total` = the
// spec's scheduled enemies — the capsule's denominator). `resolved` starts at **null**: it is only adopted from a
// real report, so `resolved ?? killed` fallbacks (an unreported field, a synthetic result) keep working — a 0 here
// would read as "nothing resolved yet" and could never be told apart from a reported 0.
progress: { gt: 0, killed: 0, total, leaks: 0, resolved: null, done: false }, lastProgressAt: this.sched.now(),
bossAcked: 0, bossBy: {}, lpAcked: 0, lpCum: 0, deadlineTimer: null, doneTimer: null, waitTimer: null,
// boss fields: the latest client reports (re-credited as the plausibility budget grows), the server run's
// CreditPool, humans demoted for an implausible result (never the authority of this field again), a 'cleared'
// b.result waiting for the budget to credit the pool it emptied (`heldResult`, _onResult)
bossReported: null, lpReported: 0, credit: null, demoted: new Set(), heldResult: null,
// b.result waiting for the budget to credit the pool it emptied (`heldResult`, _onResult); `leaksBy`: the authority's
// per-player split of the LP its enemy leaks cost (b.progress, the highest value seen; null until one carried it),
// which a result must not contradict before a perfect-payout bounty pays (_bossLeaksAgree)
bossReported: null, lpReported: 0, credit: null, demoted: new Set(), heldResult: null, leaksBy: null,
};
}
@@ -84,7 +91,7 @@ export class MatchClientCombat {
return {
t: 'b.start', battleId: f.battleId, fieldId: f.fieldId, kind: f.kind,
spec: this.spectators.has(pid) ? this._spectatorSpec(f) : f.spec,
authoritative: !!(!f.done && f.mode === 'client' && f.authority === pid && !watch),
authoritative: !!(!f.done && !f.verifyJob && f.mode === 'client' && f.authority === pid && !watch),
startAt: f.startAt, serverNow: this.sched.now(), elapsed: Math.round(this._fieldElapsed(f) * 1000) / 1000,
speed: this.gameSpeed, watch: !!watch, done: !!f.done,
};
@@ -121,6 +128,7 @@ export class MatchClientCombat {
/** A client field's result deadline: its time limit on the field clock + RESULT_GRACE_MS (then the server takes over). */
_armDeadline(f) {
if (f.deadlineTimer) { this.cancel(f.deadlineTimer); f.deadlineTimer = null; }
if (f.verifyJob) return;
const lim = f.spec.timeLimit > 0 ? f.spec.timeLimit : 60;
const at = f.startAt + Math.round((lim / this.gameSpeed) * 1000) + RESULT_GRACE_MS;
f.deadlineTimer = this.later(Math.max(0, at - this.sched.now()), () => {
@@ -225,7 +233,7 @@ export class MatchClientCombat {
/** An authoritative human disconnected / left: normal & 联防 fields → server takeover; boss → the partner or the server. */
_authorityLost(ps, why) {
for (const f of this.fields) {
if (!f.cc || f.done || f.mode !== 'client' || f.authority !== ps.playerId) continue;
if (!f.cc || f.done || f.verifyJob || f.mode !== 'client' || f.authority !== ps.playerId) continue;
if (f.kind === 'boss' || f.kind === 'hidden') this._bossHandover(f, why);
else this._runOnServer(f, why);
}
+3 -1
View File
@@ -34,7 +34,9 @@ export class MatchIntents {
case 'g.art': return ps.useArt(msg.itemUid, msg.row, msg.col, msg.dir);
case 'g.destroy': return ps.destroy(msg.uid);
case 'g.reward': return ps.pickReward(msg.idx);
case 'g.choice': return this.pickCard(ps, msg.idx);
case 'g.choice': return msg.choiceId !== undefined
? this.pickPersonalChoice(ps, msg.idx, msg.choiceId)
: this.pickCard(ps, msg.idx);
case 'g.ready': return ps.setReady(!!msg.ready);
case 'g.emote': return this.emote(ps, msg.id);
// playerId: the player tapped (a shared field names two) — the watch preference (item 56)
+28 -2
View File
@@ -43,8 +43,9 @@ export class MatchPhases {
enterBandDraft() {
if (this.phase !== PHASE.INFO_CHECK) return;
this.phase = PHASE.BAND_DRAFT;
const order = this.order.map((p) => p.playerId);
let order = this.order.map((p) => p.playerId);
if (!this.isSolo) this.rngDraft.shuffle(order);
order = this.humansFirst(order);
const skips = this.isSolo ? 0 : this.gd.bandDraft.skipsPerPlayer;
const untimed = this.soloUntimed;
this.draft = {
@@ -57,6 +58,22 @@ export class MatchPhases {
this.markPublic();
}
/**
* The co-op room option 「AI 队友最后选择」 (this.aiPicksLast, GitHub #338): every human seat before every AI seat, each
* group in the order the draft drew (a stable partition applied AFTER the shuffle — no extra random draw: the order
* with the option off is unchanged, and with it on every random stream stands where it would without it; only the
* picks made in the new order can differ). A human is any seat that is not an AI seat (room.addBot): under AI 托管,
* disconnected or departed it still counts as a human. Used by the strategy draft and the 机变 draft
* (MatchSpDraft.enterSpDraft).
* @param {string[]} order playerIds in drawn order
* @returns {string[]}
*/
humansFirst(order) {
if (!this.aiPicksLast) return order;
const bot = (pid) => !!this.players.get(pid)?.isBot;
return [...order.filter((pid) => !bot(pid)), ...order.filter(bot)];
}
draftTurn() {
const d = this.draft;
if (!d) return null;
@@ -197,7 +214,16 @@ export class MatchPhases {
if (d.order.length - d.idx <= 1) return fail(ERR.BAD_TARGET, 'nobody to pass to');
d.skipsLeft[ps.playerId]--;
d.order.splice(d.idx, 1);
d.order.push(ps.playerId);
// the skipper goes to the end; with 「AI 队友最后选择」 to the end of the humans still to pick — behind them, ahead of
// the AI seats — and to the very end only when no other human is left to pass to [ASSUMED: the option's intent,
// humans before AI, kept through a skip; no source, a remake option]
let at = d.order.length;
if (this.aiPicksLast) {
for (let j = d.order.length - 1; j >= d.idx; j--) {
if (!this.players.get(d.order[j])?.isBot) { at = j + 1; break; }
}
}
d.order.splice(at, 0, ps.playerId);
this.startDraftTurn();
return OK;
}
+5 -3
View File
@@ -58,18 +58,20 @@ export class MatchPlatform {
/**
* room.loadout during the match (DESIGN §16): only while INFO_CHECK runs (the briefing's 干员调配 entry); the lobby
* already checked it against the data (PlayerState.setLoadout re-checks it).
* already checked it against the data (PlayerState.setLoadout re-checks it). `ops` (0.2.2): the per-operator 潜能 /
* 练度 that come with it (undefined = unchanged).
* @param {string} playerId
* @param {Record<string, { skill: number, module: string|null }> | null} loadout
* @param {Record<string, { potential: number, cultivate: number }> | null} [ops]
* @returns {{ ok: true } | { error: string, detail?: string }}
*/
setLoadout(playerId, loadout) {
setLoadout(playerId, loadout, ops = undefined) {
const ps = this.players.get(playerId);
if (!ps || ps.isBot || ps.left) return fail(ERR.NOT_IN_ROOM);
if (this.disposed || this.ended || this.phase !== PHASE.INFO_CHECK) return fail(ERR.WRONG_PHASE, 'loadout locked for this match');
let res = OK;
this.guard(() => {
if (!ps.setLoadout(loadout)) { res = fail(ERR.BAD_TARGET, 'loadout does not match the game data'); return; }
if (!ps.setLoadout(loadout, ops)) { res = fail(ERR.BAD_TARGET, 'loadout does not match the game data'); return; }
this.markPrivate(ps);
});
return res;
+2
View File
@@ -62,6 +62,7 @@ export class MatchPrep {
};
const ready = () => {
if (!ps.ready) {
this.autoPickPersonalChoice(ps, 'random');
ps.resolveTemp();
ps.setReady(true);
}
@@ -113,6 +114,7 @@ export class MatchPrep {
if (this.phase !== PHASE.PREP) return;
for (const ps of this.alivePlayers()) {
if (ps.ready) continue;
this.autoPickPersonalChoice(ps, 'random');
ps.resolveTemp();
ps.ready = true;
ps.dirty();
+67 -27
View File
@@ -5,7 +5,7 @@
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
import { ERR } from '../../../shared/constants.js';
import { runHeadless, syntheticResult, validateClientResult, HARD_CAP_SECONDS } from '../fields.js';
import { HeadlessJob, syntheticResult, validateClientResult, HARD_CAP_SECONDS } from '../fields.js';
import { resultDigest, compactResult as compactForVerify } from '../../sim/spec.js';
import { OK, fail } from './common.js';
@@ -18,17 +18,23 @@ export class MatchReports {
_onProgress(ps, msg) {
if (!this.clientCombat) return fail(ERR.WRONG_PHASE, 'server-run combat');
const f = this._fieldByBattle(msg.battleId);
if (!f || f.done || f.mode !== 'client' || f.authority !== ps.playerId) return OK;
if (!f || f.done || f.verifyJob || f.mode !== 'client' || f.authority !== ps.playerId) return OK;
const p = f.progress;
const maxTotal = Math.max(p.total, (f.spec.spawns.length + 1) * 400);
p.gt = Math.max(p.gt, Math.min(Number(msg.gt) || 0, HARD_CAP_SECONDS));
// the latest total (spawns never reached before the limit leave it at the end)
p.total = Math.min(maxTotal, Math.max(0, msg.total | 0));
p.killed = Math.min(p.total, Math.max(p.killed, msg.killed | 0));
// the HUD capsule's numerator as the authority reported it (shared/protocol.js b.progress `resolved`). Only a real
// integer is adopted — an absent field leaves the null placeholder alone, so "not reported" and a reported 0 differ
// (a `Number(null) === 0` here would defeat every `resolved ?? killed` fallback); a lower value is kept (the final
// report drops never-spawned enemies from `total`, and a takeover may report a different number)
if (Number.isInteger(msg.resolved)) p.resolved = Math.max(0, Math.min(1e5, msg.resolved));
f.lastProgressAt = this.sched.now();
if (f.kind === 'boss' || f.kind === 'hidden') {
this._creditBoss(f, msg.bossDmg, msg.by);
this._creditLp(f, msg.leaks, true);
this._noteLeaksBy(f, msg.leaksBy);
this._checkFinalEnd();
this._broadcastPool(false);
// 4 Hz per field: b.pool carries the exact pool / team LP; m.public (boss HP, LP, progress) follows at ~1 Hz
@@ -46,7 +52,7 @@ export class MatchReports {
_onResult(ps, msg) {
if (!this.clientCombat) return fail(ERR.WRONG_PHASE, 'server-run combat');
const f = this._fieldByBattle(msg.battleId);
if (!f || f.done || f.heldResult || f.mode !== 'client' || f.authority !== ps.playerId) return OK;
if (!f || f.done || f.heldResult || f.verifyJob || f.mode !== 'client' || f.authority !== ps.playerId) return OK;
const bossLike = f.kind === 'boss' || f.kind === 'hidden';
const v = validateClientResult(f.spec, msg.result, { gd: this.gd });
if (!v.ok) {
@@ -57,7 +63,7 @@ export class MatchReports {
else this._runOnServer(f, 'invalid');
return OK;
}
let result = v.result;
const result = v.result;
if (bossLike) {
// the final b.progress normally carried everything; the result's per-player damage is a lower bound
const by = {};
@@ -91,13 +97,15 @@ export class MatchReports {
return OK;
}
this._bossResultDamage(f, result);
} else {
result = this._verifyResult(f, result);
}
f.result = result;
f.resultSource = 'client';
this._fieldDone(f);
if (bossLike) { this._checkFinalEnd(); this._broadcastPool(false); }
const accept = (verified) => {
f.result = verified;
f.resultSource = 'client';
this._fieldDone(f);
if (bossLike) { this._checkFinalEnd(); this._broadcastPool(false); }
};
if (bossLike) accept(result);
else this._verifyResult(f, result, accept);
return OK;
}
@@ -107,27 +115,59 @@ export class MatchReports {
}
/**
* SP_VERIFY: re-simulate an accepted client result ('all': now, the server's result wins on a mismatch; 'sample':
* ~1 battle in 8 in a later callback, mismatches are only logged).
* SP_VERIFY: re-simulate in headless slices. 'all' waits before accepting (the server wins on a mismatch);
* 'sample' accepts immediately and only logs differences for ~1 battle in 8, even after the field's phase ends.
*/
_verifyResult(f, result) {
if (this.verifyMode === 'off') return result;
const check = () => {
const run = runHeadless(this._specBattle(f.spec), { players: f.players });
const mine = validateClientResult(f.spec, compactForVerify(run.result), { gd: this.gd });
const server = mine.ok ? mine.result : run.result;
this.verifyStats.checked++;
if (resultDigest(server).hash !== resultDigest(result).hash) {
this.verifyStats.mismatches++;
this.log.warn?.(`[match ${this.roomCode}] ${f.fieldId}: client result differs from the server's simulation`);
return server;
_verifyResult(f, result, accept) {
if (this.verifyMode === 'off') { accept(result); return; }
const required = this.verifyMode === 'all';
const start = () => {
const job = new HeadlessJob(this._specBattle(f.spec), { players: f.players, onError: (e) => this.reportError('verify', e) });
if (required) {
// The result has arrived: duplicate reports, disconnects and the old deadline must not start another run.
f.verifyJob = job;
this.cancel(f.deadlineTimer);
f.deadlineTimer = null;
f.rearmDeadline = false;
}
return null;
const schedule = () => {
const timer = this.later(0, slice);
if (required) f.verifyTimer = timer;
};
const slice = () => {
if (required) f.verifyTimer = null;
if (this.disposed || this.ended) return;
if (required && (f.verifyJob !== job || f.done || f.mode !== 'client' || !this.fields.includes(f))) return;
let verified = result;
try {
if (!job.run(this.headlessSliceMs)) { schedule(); return; }
const run = job.output();
const mine = validateClientResult(f.spec, compactForVerify(run.result), { gd: this.gd });
const server = mine.ok ? mine.result : run.result;
this.verifyStats.checked++;
if (resultDigest(server).hash !== resultDigest(result).hash) {
this.verifyStats.mismatches++;
this.log.warn?.(`[match ${this.roomCode}] ${f.fieldId}: client result differs from the server's simulation`);
verified = server;
}
} catch (e) {
this.reportError('verify', e);
if (required) {
this._clearFieldTimers(f);
this._runOnServer(f, 'verify-error');
}
return;
}
if (required) { f.verifyJob = null; accept(verified); }
};
if (Number.isFinite(this.headlessSliceMs)) schedule();
else slice(); // Virtual schedulers retain their synchronous fast path.
};
if (this.verifyMode === 'all') return check() || result;
if (required) { start(); return; }
let h = 0;
for (let i = 0; i < f.battleId.length; i++) h = (h * 31 + f.battleId.charCodeAt(i)) >>> 0;
if (h % 8 === 0) this.later(0, () => { try { check(); } catch (e) { this.reportError('verify', e); } });
return result;
// Sample jobs use only match-level timers: field completion must not cancel the diagnostic work.
if (h % 8 === 0) this.later(0, start);
accept(result);
}
}
+62 -4
View File
@@ -1,22 +1,28 @@
// server/match/match/spDraft.js — Match methods: the 机变 draft (SP_DRAFT: turn order, timers, AI picks, the card applied
// by choices.js applyCard) and the rolls the meta effects ask for — bounties (addBounty), item ids (rollItemId) and
// choices.json pools (rollPool).
// by choices.js applyCard), the personal bounty choice of the Art 教鞭 (offerBountyChoice / pickPersonalChoice /
// autoPickPersonalChoice: held by the player inside PREP — no phase, no timer of its own) and the rolls the meta effects
// ask for — bounties (addBounty), item ids (rollItemId) and choices.json pools (rollPool).
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
import { PHASE, ERR } from '../../../shared/constants.js';
import { generateDraft, applyCard, bountyBattles, isMultiRoundBounty } from '../choices.js';
import { generateDraft, applyCard, bountyBattles, isMultiRoundBounty, bountyCard } from '../choices.js';
import { weightedPick } from '../waves.js';
import { botPickCard } from '../bot.js';
import { OK, fail, DELAYS } from './common.js';
/** Cards of the personal choice (PRTS 卫戍协议:盟约 下半 §法术 教鞭: "于3个战术特训的悬赏任务中选择一项"). */
const PERSONAL_OFFER_SIZE = 3;
export class MatchSpDraft {
enterSpDraft() {
const draft = generateDraft(this.gd, this.rngDraft, this.round, { stageId: this.stageId, bondAvailable: (bondId) => this.bondLive(bondId) });
const alive = this.alivePlayers();
if (!draft || !alive.length) { this.enterPrep(); return; }
this.phase = PHASE.SP_DRAFT;
const order = alive.map((p) => p.playerId);
let order = alive.map((p) => p.playerId);
if (!this.isSolo) this.rngDraft.shuffle(order);
// 「AI 队友最后选择」 (GitHub #338): humans before AI seats, after the same shuffle (MatchPhases.humansFirst)
order = this.humansFirst(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 };
@@ -106,6 +112,58 @@ export class MatchSpDraft {
this.enterPrep();
}
/**
* 教鞭 (content/items/meta.js, ctx.offerBountyChoice): the Art opens a PERSONAL choice (act2autochess choice event
* `hunter_band_1`, choiceType PERSONAL_CHOOSE) between up to three of `candidates` — drawn once from the meta rng, kept in
* `ps.personalChoice` (m.private only) until the owner confirms one with `g.choice { idx, choiceId }`. It lives inside
* PREP: no phase of its own and no timer — the prep's deadline resolves it (prepDeadline), Ready is refused while it is
* pending (PlayerPrep.setReady). Refused — before an rng draw or an id is used, so the Art stays in the hand — when it
* is not the player's PREP, a choice is already open or no card is left to offer.
* @param {import('../PlayerState.js').PlayerState} ps @param {any[]} candidates cards.bounty entries
* @param {string} sourceItemId the Art
*/
offerBountyChoice(ps, candidates, sourceItemId) {
if (this.phase !== PHASE.PREP) return fail(ERR.WRONG_PHASE);
if (!ps.alive) return fail(ERR.ELIMINATED);
if (ps.ready) return fail(ERR.WRONG_PHASE, 'ready');
if (ps.personalChoice) return fail(ERR.BAD_TARGET, '请先完成当前教鞭选择'); // i18n-ignore: developer error detail
if (!candidates.length) return fail(ERR.BAD_TARGET, '当前没有可用的战术特训'); // i18n-ignore: developer error detail
const cards = this.rngMeta.shuffle(candidates.slice()).slice(0, PERSONAL_OFFER_SIZE);
ps.personalChoice = { id: `${this.battlePrefix}.choice.${this.nextUid()}`, round: this.round, sourceItemId, cards };
ps.dirty();
return OK;
}
/** g.choice { idx, choiceId }: the owner's pick of its open personal choice — the card becomes a bounty on its next battles. */
pickPersonalChoice(ps, idx, choiceId) {
if (this.phase !== PHASE.PREP) return fail(ERR.WRONG_PHASE);
if (!ps.alive) return fail(ERR.ELIMINATED);
if (ps.ready) return fail(ERR.WRONG_PHASE, 'ready');
const pending = ps.personalChoice;
if (!pending || pending.id !== choiceId || pending.round !== this.round) return fail(ERR.BAD_TARGET);
if (!Number.isInteger(idx) || idx < 0 || idx >= pending.cards.length) return fail(ERR.BAD_TARGET);
if (!this.addBounty(ps, pending.cards[idx])) return fail(ERR.BAD_TARGET);
ps.personalChoice = null;
ps.dirty();
return OK;
}
/**
* The pick made for a player who did not make it. 'bot': an AI seat or a human under AI 托管 — the bounty scorer of the
* 机变 draft (bot.js botPickCard: the expected pay minus the expected LP lost against the own board, the bots' seeded rng
* only breaks ties); 'random': the prep's deadline and a seat the engine finishes by other means — one of the cards at
* random from the meta rng, like a 机变 turn that runs out. Nothing pending: nothing happens, no rng draw.
*/
autoPickPersonalChoice(ps, mode) {
const pending = ps.personalChoice;
if (!pending) return undefined;
const indices = pending.cards.map((c, i) => i);
const idx = mode === 'bot'
? botPickCard(this, ps, pending.cards.map((c) => bountyCard(this.gd, c)), indices)
: this.rngMeta.pick(indices);
return this.pickPersonalChoice(ps, idx, pending.id);
}
addBounty(ps, card) {
if (!ps || !card || !this.gd.enemy(card.enemyKey)) return null;
// a multi-round card lasts MULTI_ROUND_BOUNTY_BATTLES battles (choices.js; the user's call after playtest #6)
+2 -1
View File
@@ -126,8 +126,9 @@ export class MatchUnite {
let live = null;
if (f && f.cc) {
if (f.mode === 'server' && f.timeline) {
// the sample is [gt, killed, total, resolved(, left)]: a 联防 sample's `left` is its 5th element
const sample = timelineAt(f.timeline, this._fieldElapsed(f));
live = sample && sample[3] && typeof sample[3] === 'object' ? sample[3] : null;
live = sample && sample[4] && typeof sample[4] === 'object' ? sample[4] : null;
} else live = f.progress && f.progress.left && typeof f.progress.left === 'object' ? f.progress.left : null;
} else if (f && f.battle) {
try { live = uniteLeft(f.battle); } catch { live = null; }
+62 -15
View File
@@ -13,6 +13,25 @@ import { timelineAt } from '../fields.js';
import { bossFieldPlacement } from '../finalAssault.js';
import { battleProgress } from '../../sim/spec.js';
/** A reported capsule numerator clamped to its denominator, else null (unknown — never a fabricated 0). */
const finiteOrNull = (v, cap = Infinity) => {
if (v == null) return null; // Number(null) === 0: an unreported value must not read as "0 resolved"
const n = Number(v);
if (!Number.isFinite(n)) return null;
return Math.max(0, Math.min(Number.isFinite(cap) ? cap : Infinity, Math.trunc(n)));
};
/**
* A scouted operator's potential (below 6) and 练度 (0.2.2), like the sim's UnitInfo: PlayerState.loadoutFor's
* `potential` / `cultivate` (null for a stand-in or a prototype 自选 pick: neither field).
*/
function cultivationInfo(lo) {
const out = {};
if (lo && Number.isInteger(lo.potential) && lo.potential < 6) out.potential = lo.potential;
if (lo && Number.isInteger(lo.cultivate)) out.cultivate = lo.cultivate;
return out;
}
export class MatchViews {
statusOf(ps) {
if (ps.left) return 'left';
@@ -134,25 +153,45 @@ export class MatchViews {
return v;
}
/** m.public.fields[].progress: { killed, total, done } (teammates' waiting UI). */
/**
* m.public.fields[].progress: { killed, resolved, total, done } (teammates' waiting UI). `resolved` is the HUD
* capsule's numerator — the field's own scheduled enemies knocked out or leaked (Battle.resolved / the reported
* b.progress `resolved`); it is `null` (never 0) while unknown, so the client's `resolved ?? killed` fallback holds.
* `total` is the capsule's denominator: only the enemies the round scheduled (runtime splits / summons — boss summons
* included — are in neither part).
*/
_fieldProgress(f) {
if (!f) return null;
if (!f.cc) {
const b = f.battle;
if (!b) return null;
return { killed: Number(b.killed) || 0, total: Number(b.total) || 0, done: !f.live };
const total = Number(b.total) || 0;
return { killed: Number(b.killed) || 0, resolved: finiteOrNull(b.resolved, total), total, done: !f.live };
}
if (f.done && f.result) {
let killed = 0, total = 0;
for (const pp of Object.values(f.result.perPlayer || {})) { killed += Number(pp && pp.killed) || 0; total += Number(pp && pp.total) || 0; }
if (f.result.synthetic) { killed = f.progress.killed; total = f.progress.total; }
return { killed, total, done: true };
let killed = 0, total = 0, own = 0, ownKnown = true;
for (const pp of Object.values(f.result.perPlayer || {})) {
killed += Number(pp && pp.killed) || 0;
total += Number(pp && pp.total) || 0;
if (Number.isFinite(pp && pp.resolved)) own += Number(pp.resolved); else ownKnown = false;
}
// the FIELD's numerator (the validated client result / Battle.result(): what the capsule showed) — not the sum of the
// players' own: an enemy that spawns on one half and leaks on the other (a 联防 lane, the boss pair's crossing routes)
// is billed to one player's `total` and to the other's leak, so their own min(total, …) clamp it to 0
let resolved = finiteOrNull(f.result.resolved, total);
if (resolved == null && ownKnown) resolved = Math.min(total, own);
if (f.result.synthetic) { killed = f.progress.killed; total = f.progress.total; resolved = finiteOrNull(f.progress.resolved, total); }
return { killed, resolved, total, done: true };
}
if (f.mode === 'server' && f.timeline) {
const [, killed, total] = timelineAt(f.timeline, this._fieldElapsed(f));
return { killed, total, done: false };
// a server-run / bot field: no authority ever sends a b.progress, so the capsule reads the battle's own counters —
// the timeline sample carries `resolved` (Battle.resolved: knocked out + leaked among the field's own enemies),
// never the report-driven `progress.leaks`, which would leave such a field at 0 forever
const [, killed, total, resolved] = timelineAt(f.timeline, this._fieldElapsed(f));
return { killed, resolved: finiteOrNull(resolved, total), total, done: false };
}
return { killed: f.progress.killed, total: f.progress.total, done: false };
const total = f.progress.total;
return { killed: f.progress.killed, resolved: finiteOrNull(f.progress.resolved, total), total, done: false };
}
/**
@@ -204,7 +243,8 @@ export class MatchViews {
return p ? { charId: p.charId, skillIndex: p.skillIndex, uniEquipId: p.uniEquipId } : undefined;
}
/** UnitInfo of a player's board pieces on their (board) tiles — a prep scout's board, the boss partner's (bossMateView). */
/** UnitInfo of a player's board pieces on their (board) tiles — a prep scout's board, the boss partner's (bossMateView).
* `area: 'board'` (a bench unit says 'hand' / 'temp': prepFieldMeta). */
_prepBoardUnits(ps) {
const units = [];
// the player's own view of the data (0.2.0 自选编队: its slotted DIY slots are its operators — player/diy.js)
@@ -216,10 +256,11 @@ export class MatchViews {
const rec = piece.kind === 'token' ? gd.token(piece.id) : ps.fieldRecord(chess);
const assets = (rec && rec.assets) || {};
// DESIGN §16: the skill / module THIS player's operator fights with (the scout's detail card shows it, like the
// sim's UnitInfo in a shared field); moduleId only for an elite
// sim's UnitInfo in a shared field); moduleId only for an elite; 0.2.2 its potential (below 6) and 练度
const lo = piece.kind === 'chess' && chess ? ps.loadoutFor(chess) : null;
units.push({
id: piece.uid, uid: piece.uid, kind: piece.kind === 'token' ? 'token' : 'op', side: 'ally', ownerId: ps.playerId, defId: piece.id,
area: 'board',
name: rec ? rec.name : piece.id, tier: rec && Number.isInteger(rec.tier) ? rec.tier : 1, golden: !!(rec && rec.isGolden),
spine: assets.spine || (rec && rec.charId) || piece.id, avatar: assets.avatar || (rec && rec.charId) || piece.id,
x: c, y: r, dir: pieceDir(piece), facing: pieceDir(piece) === 'LEFT' ? -1 : 1, maxHp: rec && rec.stats && Number.isFinite(rec.stats.maxHp) ? rec.stats.maxHp : 1,
@@ -229,6 +270,7 @@ export class MatchViews {
items: piece.kind === 'chess' && Array.isArray(piece.items) && piece.items.length ? piece.items.map((it) => it.id) : undefined,
standInFor: rec && rec.standInFor ? rec.standInFor : undefined,
diy: this._diyInfo(ps, piece),
...cultivationInfo(lo),
});
}
return units;
@@ -271,7 +313,11 @@ export class MatchViews {
// (test/match/spectator.test.js). User playtest #2 item 1 (GitHub #44).
// (a held chess the player fields as its stand-in is the stand-in there too — name, art, max HP — and carries
// `standInFor`, like a board piece: the owner's recall of the official mode, 2026-10-06, the hand shows the stand-in)
const benchUnit = (piece, i, y) => {
// Each unit says where it waits (`area` 'hand' | 'temp'; a board unit 'board'): the bond popup of a watched player
// counts it like the player's own (ui/watchBonds.js ownerBoard — a hand operator is owned and counts for 投资人 远见
// 奇迹, a temp one counts for nothing; GitHub #385, PR #387). The tag survives the boss-half remap (_onBossHalf), the
// row does not.
const benchUnit = (piece, i, y, area) => {
const rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : gd.chess(piece.id);
const standIn = piece.kind === 'chess' && rec && ps.fieldsStandIn(rec) ? this.gd.standIn(rec.chessId) : null;
const body = standIn || rec;
@@ -279,7 +325,7 @@ export class MatchViews {
const lo = piece.kind === 'chess' && rec ? ps.loadoutFor(rec) : null;
units.push({
id: piece.uid, uid: piece.uid, kind: piece.kind === 'token' ? 'token' : piece.kind === 'item' ? 'item' : 'op',
side: 'ally', ownerId: ps.playerId, defId: piece.id,
side: 'ally', ownerId: ps.playerId, defId: piece.id, area,
name: body ? body.name : piece.id, tier: rec && Number.isInteger(rec.tier) ? rec.tier : 1, golden: !!(rec && rec.isGolden),
spine: assets.spine || (body && body.charId) || piece.id, avatar: assets.avatar || (body && body.charId) || piece.id,
x: i, y, maxHp: body && body.stats && Number.isFinite(body.stats.maxHp) ? body.stats.maxHp : 1,
@@ -288,13 +334,14 @@ export class MatchViews {
items: piece.kind === 'chess' && Array.isArray(piece.items) && piece.items.length ? piece.items.map((it) => it.id) : undefined,
standInFor: standIn && standIn.standInFor ? standIn.standInFor : undefined,
diy: this._diyInfo(ps, piece),
...cultivationInfo(lo),
});
};
for (let i = 0; i < ps.hand.length; i++) {
if (ps.hand[i]) benchUnit(ps.hand[i], i, GEO.HAND_ROW);
if (ps.hand[i]) benchUnit(ps.hand[i], i, GEO.HAND_ROW, 'hand');
}
for (let i = 0; i < ps.temp.length; i++) {
if (ps.temp[i]) benchUnit(ps.temp[i], GEO.TEMP_C0 + i, GEO.TEMP_ROW);
if (ps.temp[i]) benchUnit(ps.temp[i], GEO.TEMP_C0 + i, GEO.TEMP_ROW, 'temp');
}
// `nextEnemies`: the scouted player's coming enemies — their preview pen shows on the scouting board too (research 09
// §2.2 "Teammates"; render/app.js enterBattle({ prep: true, nextEnemies }))
+5 -34
View File
@@ -8,7 +8,6 @@
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
import { PHASE, ERR } from '../../../shared/constants.js';
import { boardOrder, pieceDir } from '../board.js';
import { snapFrame } from '../fields.js';
import { OK, fail } from './common.js';
@@ -238,38 +237,9 @@ export class MatchWatch {
}
}
/** Board signature of a prep scout view (board, hand and temp rows: a shop or funds change is not a board change).
* Hand / temp entries carry their slot — `prepFieldMeta` draws x from it, so a piece moved to another slot is a
* change (review of PR #129). In a boss round's prep the pair partner's board is part of the view (item 51). */
_prepScoutSig(ps) {
const parts = [];
const boardSig = (q) => {
for (const { r, c, piece } of boardOrder(q.board)) {
const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : '';
parts.push(`${piece.uid}:${piece.id}@${r},${c}:${pieceDir(piece)}:${items}`);
}
};
boardSig(ps);
const m = this._bossMateOf(ps);
if (m) { parts.push(`|mate:${m.mate.playerId}:${m.side}`); boardSig(m.mate); }
for (let i = 0; i < ps.hand.length; i++) {
const piece = ps.hand[i];
if (!piece) continue;
const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : '';
parts.push(`h${i}:${piece.uid}:${piece.id}:${items}`);
}
for (let i = 0; i < ps.temp.length; i++) {
const piece = ps.temp[i];
if (!piece) continue;
const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : '';
parts.push(`t${i}:${piece.uid}:${piece.id}:${items}`);
}
return parts.join(';');
}
/**
* Push prepFieldMeta to whoever is scouting `ps` during prep (GitHub #87). `to` always receives the current board
* (the player who just asked to watch); everyone scouting it receives a new `m.field` only when the board changed.
* Push prepFieldMeta to whoever is scouting `ps` during prep (GitHub #87). `to` always receives the current view
* (the player who just asked to watch); everyone scouting it receives a new `m.field` only when that view changed.
* A live battle field owns the `n:<pid>` id, so this stays quiet once fields exist.
*/
_notifyPrepScouts(ps, { to = null } = {}) {
@@ -277,12 +247,13 @@ export class MatchWatch {
const fid = `n:${ps.playerId}`;
const watchers = this.watchersOf(fid);
if (!watchers.length) return;
const sig = this._prepScoutSig(ps);
const meta = this.prepFieldMeta(ps);
// Deduplicate the public payload itself: effects and enemy previews can change without any piece moving (#346).
const sig = JSON.stringify(meta);
const changed = sig !== ps._prepScoutSig;
ps._prepScoutSig = sig;
const dest = changed ? watchers : (to ? [to] : []);
if (!dest.length) return;
const meta = this.prepFieldMeta(ps);
for (const pid of dest) this.sendTo(pid, meta);
}
}
+59 -8
View File
@@ -1,6 +1,6 @@
// server/match/player/basics.js — PlayerState methods: the basics — seat flags and counts (isHumanActive,
// botControlled, deployCap, deployCount, tempEmpty), the prep that resolves a temp piece (tempDue, _putTemp: every
// write into a temp slot), the operator loadout (DESIGN §16: setLoadout, loadoutFor), the not-owned operators fielded
// write into a temp slot), the operator loadout (DESIGN §16: setLoadout, loadoutFor; 0.2.2 潜能 / 练度: cultivationFor), the not-owned operators fielded
// as their stand-ins (0.2.0 补位: setNotOwned, fieldsStandIn, fieldRecord), the deploy map on the field the
// player deploys on (Match.deployFieldOf) and the withdrawal of pieces a terrain / deploy-field change left on tiles
// they may no longer occupy (_evictIllegal), dirty.
@@ -9,9 +9,19 @@
import { PHASE } from '../../../shared/constants.js';
import { msg, dn } from '../../../shared/i18n.js';
import { checkLoadout, checkNotOwned, resolveLoadout } from '../../../shared/protocol.js';
import { checkLoadout, checkLoadoutOps, cultivationCharIds, checkNotOwned, resolveLoadout } from '../../../shared/protocol.js';
import { cultivationOf } from '../../../shared/potential.js';
import { tileKey, boardOrder } from '../board.js';
/** The charIds a player may set a potential / 练度 for, per game-data object (shared/protocol.js cultivationCharIds). */
const OPS_IDS = new WeakMap();
function opsCharIds(raw) {
if (!raw || typeof raw !== 'object') return new Set();
let ids = OPS_IDS.get(raw);
if (!ids) { ids = cultivationCharIds(raw.chess, raw.backups); OPS_IDS.set(raw, ids); }
return ids;
}
export class PlayerBasics {
get isHumanActive() { return !this.isBot && !this.left; }
/** The engine acts for this seat (AI teammate or "AI 托管"; a departed human is eliminated, so nothing is left to do). */
@@ -46,14 +56,35 @@ export class PlayerBasics {
}
/**
* Replace the operator loadout (DESIGN §16) after re-checking it against this match's data. Accepts the checked
* form `{ id: { skill, module|null } }` or raw `room.loadout` entries. Returns false (loadout unchanged) when it does
* not fit the data; bots keep the defaults.
* Replace the operator loadout (DESIGN §16) — and, when `ops` is given, the per-operator 潜能 / 练度 (0.2.2; null = none
* set, undefined = unchanged) — after re-checking them against this match's data. Accepts the checked form
* `{ id: { skill, module|null } }` or raw `room.loadout` entries, and the checked or raw `ops`. Returns false (nothing
* changes) when either does not fit the data; bots keep the defaults.
* @param {any} loadout
* @param {any} [ops]
* @returns {boolean}
*/
setLoadout(loadout) {
setLoadout(loadout, ops = undefined) {
if (this.isBot) return false;
let opsRes = null;
if (ops !== undefined) {
const raw = {};
if (ops && typeof ops === 'object' && !Array.isArray(ops)) {
for (const [id, e] of Object.entries(ops)) {
if (!e || typeof e !== 'object') continue;
const x = {};
if (Number.isInteger(e.potential)) x.potential = e.potential;
if (Number.isInteger(e.cultivate)) x.cultivate = e.cultivate;
if (Object.keys(x).length) raw[id] = x;
}
}
const ids = opsCharIds(this.gd.raw);
opsRes = checkLoadoutOps(raw, (id) => ids.has(id));
if (!opsRes || !opsRes.ok) {
this.m.log?.warn?.(`[match ${this.m.roomCode}] operator settings of ${this.playerId} ignored: ${opsRes && opsRes.detail}`);
return false;
}
}
const entries = {};
if (loadout && typeof loadout === 'object' && !Array.isArray(loadout)) {
for (const [id, e] of Object.entries(loadout)) {
@@ -72,9 +103,27 @@ export class PlayerBasics {
const out = {};
for (const [id, e] of Object.entries(res.loadout)) out[id] = Object.freeze({ skill: e.skill, module: e.module ?? null });
this.loadout = Object.freeze(out);
if (opsRes) {
const o = {};
for (const [id, e] of Object.entries(opsRes.ops)) o[id] = Object.freeze({ potential: e.potential, cultivate: e.cultivate });
this.ops = Object.freeze(o);
}
return true;
}
/**
* The potential / 练度 this player's piece of chess record `chessRecord` fights at (0.2.2; shared/potential.js
* cultivationOf over `this.ops` — defaults 潜能 6, 精英2 Lv.60): the operator the player owns — the chess fielded as
* itself, an owned 自选 pick (its charId) — or null for a 补位 stand-in and a prototype 自选 pick.
* @param {object|null} chessRecord
* @returns {{ potential: number, cultivate: number } | null}
*/
cultivationFor(chessRecord) {
if (!chessRecord || this.fieldsStandIn(chessRecord)) return null;
const rec = chessRecord.isDiy && typeof chessRecord.chessId === 'string' ? this.gd.chess(chessRecord.chessId) || chessRecord : chessRecord;
return cultivationOf(rec, this.ops);
}
/**
* The skill index / module a chess record fights with under this player's loadout (DESIGN §16) — for a chess this
* player fields as its stand-in (0.2.0 补位) the stand-in's backup selection, whatever the loadout says; for a slotted
@@ -83,10 +132,12 @@ export class PlayerBasics {
*/
loadoutFor(chessRecord) {
if (this.fieldsStandIn(chessRecord)) {
return resolveLoadout(null, this.gd.standIn(chessRecord.chessId), (id) => this.gd.standIn(id) || this.gd.chess(id));
return { ...resolveLoadout(null, this.gd.standIn(chessRecord.chessId), (id) => this.gd.standIn(id) || this.gd.chess(id)), potential: null, cultivate: null };
}
const rec = chessRecord && chessRecord.isDiy && typeof chessRecord.chessId === 'string' ? this.gd.chess(chessRecord.chessId) || chessRecord : chessRecord;
return resolveLoadout(this.loadout, rec, (id) => this.gd.chess(id));
// 0.2.2: with the operator's potential / 练度 (null for a prototype 自选 pick) — the 自选 summons' hand count reads it
const cv = this.cultivationFor(chessRecord);
return { ...resolveLoadout(this.loadout, rec, (id) => this.gd.chess(id)), potential: cv ? cv.potential : null, cultivate: cv ? cv.cultivate : null };
}
/**
+4 -2
View File
@@ -36,6 +36,7 @@
import { KITTED_CHARS } from '../../sim/content/kits/index.js';
import { checkDiyPicks } from '../../../shared/protocol.js';
import { diyRecord, diySlot, diyTokenOwner } from '../../../shared/diy.js';
import { atPotential } from '../../../shared/potential.js';
const posIntOr = (v, d) => (Number.isInteger(v) && v > 0 ? v : d);
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
@@ -102,7 +103,8 @@ export function diyGameData(gd, records) {
* placeable, by the variant of the owner form (`bySkill[skillIndex]` sources) — the deploy limit as the count (PRTS
* 卫戍协议/帮助 "根据召唤物部署数量上限(非初始持有量)"), the active module's own when its variant has one (`byModule`:
* 望's TRP-X "可同时部署的陷阱数量提升", 6 → 7 棋子; SUM-Y stage 2+ 4 drones / summons). The data's deploy limit holds the
* token's own talent additions (tools/build-data.mjs tokenTalentDeckBonus, 0.2.0): 麦哲伦 / 令 / 电弧 3, 白铁 2, 夜莺 3 幻影.
* token's own talent additions (tools/build-data.mjs tokenTalentDeckBonus, 0.2.0): 麦哲伦 / 令 / 电弧 3, 白铁 2, 夜莺 3 幻影 —
* at the owner's potential (`loadout.potential`, PlayerState.loadoutFor; 0.2.2: 望's 棋子 6 below 潜能3, 7 from it).
*/
placeableTokens: {
value: (chessId, loadout = null) => {
@@ -113,7 +115,7 @@ export function diyGameData(gd, records) {
for (const tid of Array.isArray(rec.tokens) ? rec.tokens : []) {
const t = tokenOf(tid);
if (!t || t.kind !== 'summon' || t.placeable !== true) continue;
const v = isObj(t.variants) ? t.variants[owner] ?? null : null;
const v = isObj(t.variants) ? atPotential(t.variants[owner] ?? null, loadout?.potential) : null;
if (v) {
const alt = loadout && Number.isInteger(loadout.skillIndex) && v.bySkill ? v.bySkill[loadout.skillIndex] : null;
const src = Array.isArray(alt?.sources) ? alt.sources : Array.isArray(v.sources) ? v.sources : [];
+13 -9
View File
@@ -93,11 +93,14 @@ export class PlayerEconomy {
}
/**
* The 调度中心's upgrade opens the new level's extra slots at once, each with a new card drawn at the new level; the
* cards already shown stay where they are (chess slots keep their index, the item slot stays after them). Official: the
* tutorial's 休整期 page 「升级:…升级后将出现更多的商品栏位、可调度干员以及新装备」; the community report of 2026-10-06
* (item 19) 「升级商店获得新的商店位时用新卡补上,而不是空着」. Until 0.2.0 the extra slots waited for the next roll (a
* refresh or the round start). [ASSUMED] the new card follows the freeze toggle, as a manual refresh's cards do.
* The 调度中心's upgrade opens the new level's extra slots at once — EMPTY: no card is drawn into them (a `null` slot).
* The next roll (a manual refresh or the round start) fills them with the other unfrozen slots; the cards already shown
* stay where they are (chess slots keep their index, the item slot stays after them). Official: the footage of a 1→2
* upgrade (bilibili BV1AXwuzdEys 1:39, GitHub #332 / PR #333 by @2321Robin) shows the new slot appear and stay empty, and
* the texts only name the slots — the tutorial's 休整期 page 「升级后将出现更多的商品栏位」, PRTS 帮助 「增加刷新栏位」 —,
* never a card that comes with them. 0.2.0 drew a card into each new slot instead (item 19 of the community report of
* 2026-10-06, 「升级商店获得新的商店位时用新卡补上,而不是空着」, one uncorroborated remark; its follow-on, that the new card
* follows the freeze toggle, was [ASSUMED]); until then the extra slots waited for the next roll as well.
*/
_openLevelSlots() {
const { chess: nChess, item: nItem } = this.gd.shopSlots(this.shop.level);
@@ -106,9 +109,8 @@ export class PlayerEconomy {
if (nChess <= layout.chess && nItem <= layout.item) return;
const chess = old.slice(0, layout.chess);
const items = old.slice(layout.chess);
const fresh = (s) => { if (s) s.frozen = this.shop.frozen; return s; };
while (chess.length < nChess) chess.push(fresh(this._rollChessSlot()));
while (items.length < nItem) items.push(fresh(this._rollItemSlot()));
while (chess.length < nChess) chess.push(null);
while (items.length < nItem) items.push(null);
this.shop.slots = [...chess, ...items];
this.shop.layout = { chess: chess.length, item: items.length };
}
@@ -209,7 +211,9 @@ export class PlayerEconomy {
if (loc.piece.kind !== 'chess') return fail(ERR.BAD_TARGET, loc.piece.kind === 'item' ? 'items cannot be sold' : 'tokens cannot be sold');
const piece = loc.piece;
// its equipment returns to the hand (overflow temp): refuse rather than destroy it when there is no room
const room = this.hand.filter((x) => x == null).length + this.temp.filter((x) => x == null).length + (loc.area === 'hand' || loc.area === 'temp' ? 1 : 0);
// Removing the owner's summon stacks frees one slot per stack, regardless of its summon count.
const freed = (x) => x == null || (x.kind === 'token' && x.ownerUid === piece.uid);
const room = this.hand.filter(freed).length + this.temp.filter(freed).length + (loc.area === 'hand' || loc.area === 'temp' ? 1 : 0);
if ((piece.items || []).length > room) return fail(ERR.HAND_FULL, 'no room for the equipment');
this._detach(loc);
this.removeTokensOf(piece.uid);
+1
View File
@@ -56,6 +56,7 @@ export class PlayerPrep {
setReady(on) {
if (!this.alive) return fail(ERR.ELIMINATED);
if (this.m.phase !== PHASE.PREP) return fail(ERR.WRONG_PHASE);
if (on && this.personalChoice) return fail(ERR.BAD_TARGET, '请先完成教鞭选择'); // i18n-ignore: developer error detail
if (on && !this.tempEmpty) return fail(ERR.TEMP_NOT_EMPTY);
if (this.ready === !!on) return OK;
this.ready = !!on;
+5
View File
@@ -65,6 +65,7 @@ export class PlayerRound {
this._tempDue.clear();
this.offers = [];
this.bounties = [];
this.personalChoice = null;
this.shop.slots = [];
this.funds = 0;
this.pendingFunds = 0;
@@ -118,6 +119,10 @@ export class PlayerRound {
u.skillIndex = lo.skillIndex;
u.moduleId = lo.moduleId;
}
// 0.2.2: the potential / 练度 of an operator the player owns (the chess as itself, an owned 自选 pick — the player's
// 干员调配 settings, 潜能 6 / 精英2 Lv.60 by default and for bots; never a stand-in's nor a prototype pick's)
const cv = u.standIn ? null : this.cultivationFor(this.gd.chess(piece.id));
if (cv) { u.potential = cv.potential; u.cultivate = cv.cultivate; }
if (carry && carry.has(piece.uid)) u.carryState = carry.get(piece.uid);
units.push(u);
} else if (piece.kind === 'token') {
+11 -2
View File
@@ -6,7 +6,7 @@
import { PHASE } from '../../../shared/constants.js';
import { boardOrder, pieceDir } from '../board.js';
import { bondList, offBondCounts } from '../bondsMeta.js';
import { bountyText } from '../choices.js';
import { bountyText, bountyCard } from '../choices.js';
export class PlayerViews {
pieceView(p, rc = null) {
@@ -66,7 +66,13 @@ export class PlayerViews {
funds: this.funds,
bandId: this.bandId,
ready: this.ready,
canReady: this.alive && this.tempEmpty && this.m.phase === PHASE.PREP,
canReady: this.alive && this.tempEmpty && !this.personalChoice && this.m.phase === PHASE.PREP,
personalChoice: this.personalChoice ? {
id: this.personalChoice.id,
round: this.personalChoice.round,
sourceItemId: this.personalChoice.sourceItemId,
cards: this.personalChoice.cards.map((c) => bountyCard(this.gd, c)),
} : null,
shop: {
level: this.shop.level,
maxLevel: this.gd.maxShopLevel,
@@ -90,6 +96,9 @@ export class PlayerViews {
nextEnemies: this.m.nextEnemiesFor(this),
// DESIGN §16: the effective operator loadout ({ [baseChessId]: { skill, module } }; chess not listed use defaults)
loadout: this.loadout,
// 0.2.2: the effective per-operator 潜能 / 练度 ({ [charId]: { potential, cultivate } }; operators not listed: 潜能 6,
// 精英2 Lv.60) — the client's cards compose the numbers from it (shared/potential.js)
ops: this.ops,
// 0.2.0 补位: the base chess ids this player fields as their stand-ins in this match (the not-owned list the seat had
// at the match start; [] = every operator owned) — the client shows these as their stand-ins (cards, pieces, the
// detail card, with a small 「替补」 mark)
+4 -1
View File
@@ -62,7 +62,8 @@ export const NET_DEFAULTS = Object.freeze({
/**
* Intents that also draw from the per-connection heavy bucket: g.watch (its reply is a large state resend, m.field),
* room.loadout (a ≤ 160-entry map validated against the game data; the client debounces its edits), room.ownership
* room.loadout (a ≤ 160-entry map and ≤ 256 operator settings validated against the game data; the client debounces its
* edits), room.ownership
* (a ≤ 160-id list, the same way), room.diy (≤ 8 自选 picks checked against the data, the same way) and room.spectate
* (taking a spectator seat in a running match resends its state like a watcher's g.watch — server/lobby.js spectate).
*/
@@ -109,6 +110,8 @@ export class Session {
this.resyncAt = -Infinity;
/** @type {Record<string, { skill: number, module: string|null }> | null} checked operator loadout (lobby-owned, DESIGN §16) */
this.loadout = null;
/** @type {Readonly<Record<string, { potential: number, cultivate: number }>> | null} checked per-operator 潜能 / 练度 (lobby-owned, 0.2.2; room.loadout `ops`) */
this.ops = null;
/** @type {readonly string[] | null} checked not-owned chess ids (干员持有, lobby-owned, 0.2.0 补位) */
this.notOwned = null;
/** @type {Readonly<Record<string, { charId: string, skillIndex: number, uniEquipId: string|null }>> | null} checked 自选 picks (lobby-owned, 0.2.0 自选编队) */
+15
View File
@@ -148,6 +148,11 @@ export class Battle {
this.killed = 0;
this.total = 0;
this.leakedCount = 0;
// The HUD capsule's own counters (DESIGN §14, PR #157 semantics): only the enemies the field itself scheduled
// (`inTotal`, spawns.js `_queueSpawn` — `total` is their denominator) count here. `killed` / `leakedCount` above keep
// the official LP / 完美作战 reading: every `counted` enemy, runtime splits and summons included.
this.killedInTotal = 0;
this.leakedInTotal = 0;
this.errors = [];
this.errorCount = 0;
this._errKeys = new Set();
@@ -172,6 +177,16 @@ export class Battle {
if (typeof opts.setup === 'function') this._safe(() => opts.setup(this), 'opts.setup');
}
/**
* The HUD capsule's numerator (DESIGN §14): the field's own scheduled enemies (`inTotal`) that are 已解决 — knocked
* down (`killedInTotal`) or leaked (`leakedInTotal`) — never above the denominator `total`. The official reading:
* 开局 0/3 → 漏一个 1/3 → 打死一个 2/3 → 打死会分裂的 3/3; a leaked split child still costs LP (`leakedCount`) and breaks
* 完美作战, but it is no enemy of the stage's own list and moves no capsule number.
*/
get resolved() {
return Math.min(this.total, this.killedInTotal + this.leakedInTotal);
}
[Symbol.for('nodejs.util.inspect.custom')]() {
return `Battle<${this.kind} seed=${this.seed} t=${this.time.toFixed(2)} units=${this.units.length}${this.finished ? ' ' + this.reason : ''}>`;
}
+85 -25
View File
@@ -28,16 +28,18 @@
// (PRTS 状态机: an enemy's COMBAT state ends with its attack, not with its blocker); the candidates pass the
// enemy's own rule (`e.profile.canTarget`) and are ordered
// blocker → taunt → latest deployed (targeting.js sortAllyTargets). An enemy's damage type is its data's unless content
// arms it (`e.profile.dmgType`: 转译基底's forms, whose data never attacks). Reaching the final leg's end = leak. A `fear` (恐惧) status suspends the route: the
// enemy runs between random checkpoints away from the fear's source (fear.js moveFeared; a self-inflicted fear
// flutters inside its own tile); an `attract` (诱导) status walks it to the status point instead (moveAttracted);
// both re-plan the route when released (恐惧 outranks 诱导).
// arms it (`e.profile.dmgType`: 转译基底's forms, whose data never attacks). Reaching the final leg's end = leak — on a
// portal entrance it is a teleport to the far exit and a walk to that side's blue door instead (portalPickup). A `fear`
// (恐惧) status suspends the route: the enemy runs between random checkpoints away from the fear's source (fear.js
// moveFeared; a self-inflicted fear flutters inside its own tile); an `attract` (诱导) status walks it to the status
// point instead (moveAttracted); both re-plan the route when released (恐惧 outranks 诱导).
import { ATTACK_PAUSE, ALLY_COLLIDER_RADIUS, MOVE_SCALE, PROJECTILE_SPEEDS, PROJECTILE_SPEED, BOOMERANG_RETURN_SPEED, COLS, CHAIN_RADIUS } from './constants.js';
import { sortEnemyTargets, sortAllyTargets, canTargetEnemy, canTargetAlly, tileKeyOf } from './targeting.js';
import { reduceElement } from './damage.js';
import { straightClear } from './grid.js';
import { moveFeared, endFear } from './fear.js';
import { hypot, powi } from './detmath.js';
// ---------------------------------------------------------------------------------------------------------------
// profiles
@@ -73,8 +75,19 @@ export function effectiveProfile(u) {
// ---------------------------------------------------------------------------------------------------------------
// ally attack loop
/**
* One tick off an attack cooldown. What is left within 1e-9 of 0 is 0 (the engine's timer tolerance, as skills.js
* `timeLeft`, buff intervals and `every()`): 1 s counted down in thirty steps of 1/30 leaves 2.1e-16 in floating point,
* which held every attack whose interval is a whole number of ticks — 1 s, 3 s, 4 s … — one tick longer (PR #402; PRTS
* 作战机制 帧对齐: 1 s is 30 frames). A non-integer count still ends on the tick that crosses 0 (docs/SIM.md §2).
*/
export function attackCountdown(cd, dt) {
const left = cd - dt;
return left > 1e-9 ? left : 0;
}
export function updateAlly(b, u, dt) {
if (u.atkCd > 0 && u.canAct) u.atkCd = Math.max(0, u.atkCd - dt);
if (u.atkCd > 0 && u.canAct) u.atkCd = attackCountdown(u.atkCd, dt);
if (u.blocking.length) enforceBlockCapacity(b, u);
if (!u.canAct || !u.profile) return;
// a rangeExtend change (buff added / expired) rebuilds the range — also for units that never attack (auras)
@@ -325,13 +338,13 @@ export function resolveHit(b, u, prof, target, info, x, y) {
let best = null, bd = Infinity;
for (const e of b.enemiesInRadius(prev.x, prev.y, prof.chain.radius || CHAIN_RADIUS)) {
if (hit.has(e.id) || !canTargetEnemy(u, e, prof)) continue;
const d = Math.hypot(e.x - prev.x, e.y - prev.y);
const d = hypot(e.x - prev.x, e.y - prev.y);
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && e.spawnSeq < best.spawnSeq)) { bd = d; best = e; }
}
if (!best) break;
hit.add(best.id);
b._ev(['atk', prev.id, best.id, 'chain']);
const d = b.dealDamage(u, best, { amount: atk * scale * skillMul * Math.pow(1 - (prof.chain.falloff ?? 0.15), k), type: baseType, isAttack: true, isSkill: info.isSkill, tags: ['chain'], attackId });
const d = b.dealDamage(u, best, { amount: atk * scale * skillMul * powi(1 - (prof.chain.falloff ?? 0.15), k), type: baseType, isAttack: true, isSkill: info.isSkill, tags: ['chain'], attackId });
dealtTotal += d;
if (prof.chain.sluggish && best.alive) b.applyStatus(best, 'sluggish', { duration: prof.chain.sluggish, source: u });
if (each) each(best, d, 'chain');
@@ -389,7 +402,7 @@ function doHeal(b, u, prof, t) {
if (!best) break;
seen.add(best.id);
b._ev(['atk', prev.id, best.id, 'chainHeal']);
b.heal(u, best, amount * Math.pow(1 - (h.falloff ?? 0.25), k));
b.heal(u, best, amount * powi(1 - (h.falloff ?? 0.25), k));
prev = best;
}
}
@@ -475,7 +488,7 @@ function planLeg(b, e, leg) {
R.version = b.grid.version;
// suffix lengths for remaining-distance queries
const suf = new Float64Array(pts.length);
for (let i = pts.length - 2; i >= 0; i--) suf[i] = suf[i + 1] + Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y);
for (let i = pts.length - 2; i >= 0; i--) suf[i] = suf[i + 1] + hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y);
R.suffix = suf;
}
@@ -497,7 +510,7 @@ function tailLength(b, e, idx) {
let len = 0;
if (L.t === 'move') {
const [sr, sc] = starts[i];
len = Math.hypot(L.r - sr, L.c - sc);
len = hypot(L.r - sr, L.c - sc);
if (e.motion !== 'FLY' && b.grid.inBounds(sr, sc)) {
const f = legField(b, L.r, L.c, sr * COLS + sc);
const fl = f ? b.grid.fieldLength(f, sr * COLS + sc) : Infinity;
@@ -519,9 +532,9 @@ export function remainingDistance(b, e) {
const leg = R.legs[R.legIdx];
if (leg && leg.t === 'move' && R.pts && R.ptIdx < R.pts.length) {
const p = R.pts[R.ptIdx];
d += Math.hypot(p.x - e.x, p.y - e.y) + R.suffix[R.ptIdx];
d += hypot(p.x - e.x, p.y - e.y) + R.suffix[R.ptIdx];
} else if (leg && leg.t === 'move') {
d += Math.hypot(leg.c - e.x, leg.r - e.y);
d += hypot(leg.c - e.x, leg.r - e.y);
}
return d;
}
@@ -534,13 +547,18 @@ export function updateEnemy(b, e, dt) {
if (!e.alive) return;
// hidden (teleporting) enemies only advance wait legs
const stunned = e.s.flags.stun;
if (stunned || e.hidden || e.s.flags.fear) b._cutAttackStand(e);
// 失衡 (UNBALANCE, battle/displacement.js _unbalance): a state machine, not a status — 浮空 ends it (PRTS 术语释义 浮空
// 「触发浮空时清除受到的推/拉力…无法陷入失衡」); while it lasts the enemy neither walks nor starts a normal attack
if (e.s.flags.levitate && e.unbalanceUntil > b.time) e.unbalanceUntil = -Infinity;
const unbalanced = b.time < e.unbalanceUntil - 1e-9; // (the 1e-9: a float-summed end such as 35/30 s keeps its frame)
const prevCd = e.atkCd;
if (e.atkCd > 0 && !stunned && !e.hidden) e.atkCd = Math.max(0, e.atkCd - dt);
// a stun / freeze / sleep / 浮空 — or leaving the field — takes the enemy out of its attack: a swing short of its damage
// frame does not land, and the attack starts again from its wind-up afterwards (enemyAttack)
if (e.swing && (stunned || e.hidden)) e.swing = false;
if (e.atkCd > 0 && !stunned && !e.hidden) e.atkCd = attackCountdown(e.atkCd, dt);
// a stun / freeze / sleep / 浮空 / 失衡 — or leaving the field — takes the enemy out of its attack: a swing short of its
// damage frame does not land, and the attack starts again from its wind-up afterwards (enemyAttack)
if (e.swing && (stunned || e.hidden || unbalanced)) e.swing = false;
// true: an unblocked ranged enemy in the wind-up of its next attack with a target in range (it stands)
const winding = !e.hidden && !stunned && enemyAttack(b, e, prevCd);
const winding = !e.hidden && !stunned && !unbalanced && enemyAttack(b, e, prevCd);
if (!e.alive) return;
// a stun / freeze / sleep cuts the attack clip short: no stand left once it ends [ASSUMED]. 沉睡 also holds 不可阻挡
// (PRTS 异常效果 SLEEPING = 无法行动+无敌+不可阻挡): a sleeper's blocker lets go — its swing was cut above; Battle.applyStatus
@@ -569,12 +587,13 @@ export function updateEnemy(b, e, dt) {
if (b.time < e.pauseUntil) return;
// standing for an attack clip (attackStand, GitHub #58): only the walking waits — a checkpoint's WAIT keeps running
// and DISAPPEAR / APPEAR legs still happen (advanceRoute); drawn idle (the client plays the clip, then Move again);
// a 恐惧 runs at once (it cannot attack)
const standing = winding || (b.time < e.atkStandUntil && !e.s.flags.fear);
// a 恐惧 runs at once (it cannot attack). 失衡 holds the walking too — under 恐惧 / 诱导 as well (失衡免疫 says 「失衡期间
// 无法自主移动」; no source exempts a fear)
const standing = winding || unbalanced || (b.time < e.atkStandUntil && !e.s.flags.fear);
if (e.s.flags.noMove) { e.moving = false; return; } // standing (a 重生, a form change): drawn idle, not walking
// 恐惧 (ba.fear "无法被阻挡并四散逃跑"; PRTS 诱发移动: 恐惧 outranks 诱导): runs to random tiles of the fan away from
// its source — a self-inflicted fear flutters inside its own tile (fear.js); the route re-plans once it ends
if (e.s.flags.fear && !e.hidden) { moveFeared(b, e, dt); return; }
if (e.s.flags.fear && !e.hidden) { if (unbalanced) e.moving = false; else moveFeared(b, e, dt); return; }
if (e.mem.fearMove) endFear(e);
// 诱导 (ba.attract "无法被阻挡并向目标位置移动"): walks to the attract point instead of following its route
if (e.s.flags.attract) { if (standing) e.moving = false; else moveAttracted(b, e, dt); return; }
@@ -614,7 +633,7 @@ function moveAttracted(b, e, dt) {
let moved = false;
while (dist > 1e-9 && A.i < A.pts.length) {
const p = A.pts[A.i];
const dx = p.x - e.x, dy = p.y - e.y, d = Math.hypot(dx, dy);
const dx = p.x - e.x, dy = p.y - e.y, d = hypot(dx, dy);
if (d <= dist) { e.x = p.x; e.y = p.y; dist -= d; A.i++; } else { e.x += (dx / d) * dist; e.y += (dy / d) * dist; dist = 0; }
moved = true;
}
@@ -632,7 +651,10 @@ function advanceRoute(b, e, dt, R, standing = false) {
let guard = 16;
while (budget > 1e-9 && guard-- > 0 && e.alive) {
const leg = R.legs[R.legIdx];
if (!leg) { b.leak(e); return; }
if (!leg) {
if (!portalPickup(b, e)) { b.leak(e); return; }
continue;
}
if (leg.t === 'wait') {
if (R.waitLeft == null) R.waitLeft = leg.time;
const use = Math.min(budget, R.waitLeft);
@@ -643,6 +665,7 @@ function advanceRoute(b, e, dt, R, standing = false) {
continue;
}
if (leg.t === 'disappear') {
b._cutAttackStand(e);
b._setHidden(e, true);
e.atkStandUntil = -Infinity; standing = false; // off the field: its attack clip is over
R.legIdx++; R.pts = null;
@@ -651,6 +674,8 @@ function advanceRoute(b, e, dt, R, standing = false) {
if (leg.t === 'appear') {
e.x = leg.c; e.y = leg.r;
b._setHidden(e, false);
// a relocation is a forced state switch: it ends a 失衡 (PRTS 失衡位移机制 「被强制切换至其他状态机」)
e.unbalanceUntil = -Infinity;
R.legIdx++; R.pts = null;
continue;
}
@@ -667,7 +692,7 @@ function advanceRoute(b, e, dt, R, standing = false) {
if (R.ptIdx >= R.pts.length) break;
const p = R.pts[R.ptIdx];
const dx = p.x - e.x, dy = p.y - e.y;
const d = Math.hypot(dx, dy);
const d = hypot(dx, dy);
if (d <= dist) {
e.x = p.x; e.y = p.y;
dist -= d;
@@ -681,13 +706,48 @@ function advanceRoute(b, e, dt, R, standing = false) {
}
budget = dist / speed;
if (R.pts && R.ptIdx >= R.pts.length) {
if (leg.final) { b.leak(e); return; }
if (leg.final) {
if (!portalPickup(b, e)) { b.leak(e); return; }
}
R.legIdx++;
R.pts = null;
}
}
}
/**
* Boss-field portal pickup (tile_telin / tile_telout; GitHub #336, PR #337 by @2321Robin, both with a recording of the
* official game): a route that ENDS on a portal entrance does not leak there — the enemy is teleported out of the far
* exit and walks on to the blue door on the entrance's side, where it leaks. The boss / Hidden Core circuits end on
* an entrance ([1,3] / [1,17]; 137 routes of data/waves.json); their mid-route crossings are the routes' own DISAPPEAR /
* WAIT / APPEAR steps, every MOVE onto an entrance being followed by a DISAPPEAR, so only the route's end needs this.
* Appends vanish → wait → reappear → walk-to-door legs to the live route and returns true (false: not on an entrance,
* or no exit in the field). The exit is the telout farthest from the entrance, the door the end tile nearest to it:
* every explicit pair of the data (95, routes and extra routes) reads [1,3] / [1,17] → [5,10] — the farther of the two
* telouts [5,10] / [2,10] of the battle stages — and the doors [2,2] / [2,18] stand beside the entrances. Inside the
* portal the enemy stays hidden PORTAL_WAIT s, the wait of the explicit crossings of the same tiles (WAIT 3 s in 85 of
* the 95; 5 s in 9, 1 s in 1). [ASSUMED] the pairing (no tile carries a link) and the 3 s (no source times this one).
*/
const PORTAL_WAIT = 3;
function portalPickup(b, e) {
const r = Math.round(e.y), c = Math.round(e.x);
if (!b.grid.inBounds(r, c) || b.grid.tile(r, c).special !== 'telin') return false;
const outs = b.grid.specialTiles('telout');
if (!outs.length) return false;
const dist = (p) => hypot(p[0] - r, p[1] - c);
const out = outs.reduce((a, x) => (dist(x) > dist(a) ? x : a));
const R = e.route;
R.legs.splice(R.legIdx + 1, 0, { t: 'disappear' }, { t: 'wait', time: PORTAL_WAIT }, { t: 'appear', r: out[0], c: out[1] });
const ends = b.grid.specialTiles('end');
if (ends.length) {
const door = ends.reduce((a, x) => (dist(x) < dist(a) ? x : a));
R.legs.push({ t: 'move', r: door[0], c: door[1], final: true });
}
R.pts = null;
R.tailVersion = -1; // the appended tail invalidates the cached remaining-distance suffixes
return true;
}
/**
* How long an enemy stands for one attack: an unblocked ranged enemy for its whole clip (GitHub #58 — the owner's
* decision of 2026-10-04 from first-hand memory of the official game: a ranged enemy stops for each attack's animation
@@ -848,7 +908,7 @@ function enemyAttack(b, e, prevCd) {
if (!tt || !tt.alive || !e.alive && !rangedShot) return;
b.dealDamage(e, tt, { amount: e.s.atk * (e.profile?.atkScale ?? 1), type, isAttack: true, attackId, isProjectile });
};
if (rangedShot && Math.hypot(t.x - e.x, t.y - e.y) > 0.75) {
if (rangedShot && hypot(t.x - e.x, t.y - e.y) > 0.75) {
b.addProjectile({ from: e, target: t, speed: PROJECTILE_SPEEDS.enemy, visual: 'enemy', source: e, onHit: (c) => hit(c.target, true) });
} else hit(t);
}
+20 -2
View File
@@ -56,7 +56,11 @@ export class BattleDeploy {
const sk = u.skill;
// "自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却" (PRTS 卫戍协议/帮助; skills.js)
if (initial) sk.opReadyAt = this.time + this.flags.startOpCooldown;
if (keepSp && !sk.noSkill && sk.kind !== 'passive' && !sk.active) {
// keepSp (突袭 "保留技力"): the SP / charges before the jump — not for a skill that costs no SP (spCost 0, the
// deploy-timed skills), which reset() just re-armed as at every deployment: the retreat ended it, so its snapshot
// holds 0 charges, and writing that back took the charge 伊内丝 S3's deploy hook casts with (community report:
// 伊内丝激活突袭瞬移以后3技能被吞; those with activateOnDeploy run already, skipped by `!sk.active`)
if (keepSp && !sk.noSkill && sk.kind !== 'passive' && !sk.active && sk.spCost > 0) {
sk.charges = Math.max(0, Math.min(sk.maxCharges, Math.floor(fin(keepSp.charges, 0))));
sk.sp = Math.max(0, Math.min(sk.spCost, fin(keepSp.sp, 0)));
if (sk.charges >= sk.maxCharges) sk.sp = sk.spCost;
@@ -97,6 +101,7 @@ export class BattleDeploy {
}
_remove(unit, reason, killer = null, permanent = false, dying = false) {
this._cutAttackStand(unit);
unit.alive = false;
unit.removeReason = reason;
unit.deployed = false;
@@ -135,11 +140,17 @@ export class BattleDeploy {
this._unblock(unit);
this._enemiesDirty = true;
if (reason === 'killed') {
const pp = this._pp(unit.ownerId);
if (unit.counted) {
this.killed++;
const pp = this._pp(unit.ownerId);
if (pp) pp.killed++;
}
// the HUD capsule's numerator (DESIGN §14): only the enemies the stage itself scheduled (`inTotal`) — a split
// child / summon / part knocked down here is no 已解决 of the stage's own list and moves the capsule not at all
if (unit.inTotal) {
this.killedInTotal++;
if (pp) pp.killedInTotal++;
}
if (killer) killer.stats.kills++;
if (unit.bounty && unit.bounty.coins > 0) this.addCoins(this._bountyPayee(unit, killer), unit.bounty.coins);
}
@@ -252,5 +263,12 @@ export class BattleDeploy {
if (e.counted || e.isBoss) pp.perfect = false;
}
if (e.counted) this.leakedCount++;
// the HUD capsule's leak counter (DESIGN §14): only the stage's own enemies (`inTotal`) — a leaked split child or
// summon still costs LP through `counted`/`leakedCount` and still breaks 完美作战, but it is no 漏掉 of the stage's
// own list, so the capsule's numerator does not move for it
if (e.inTotal) {
this.leakedInTotal++;
if (pp) pp.leakedInTotal++;
}
}
}
+76 -16
View File
@@ -2,8 +2,12 @@
// directional), pull, the raw mover displace and who can be moved at all.
// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle).
import { COLS, PUSH_TILES, PUSH_TILES_EFFECT, PULL_WEAK_SHARE, PULL_CRAWL, PULL_ORIGIN, PULL_STOP_RADIUS, PUSH_DIRECTIONAL_MIN_DIST } from '../constants.js';
import {
COLS, PUSH_TILES, PUSH_TILES_EFFECT, PULL_WEAK_SHARE, PULL_CRAWL, PULL_ORIGIN, PULL_STOP_RADIUS, PUSH_DIRECTIONAL_MIN_DIST,
PUSH_UNBALANCE, PULL_UNBALANCE, PULL_UNBALANCE_WEAK, UNBALANCE_MIN,
} from '../constants.js';
import { fin } from './util.js';
import { hypot } from '../detmath.js';
/**
* Official push distance (tiles) of a 受力等级 (constants.js PUSH_TILES: ≤ −3 → 0, ≥ 3 → the 3 value); `effect` = a 特效
@@ -15,6 +19,18 @@ export function pushTiles(level, effect = false) {
return (effect ? PUSH_TILES_EFFECT : PUSH_TILES)[Math.min(3, l)];
}
/** 失衡 (UNBALANCE) of a push of 受力等级 `level`, game seconds (constants.js PUSH_UNBALANCE: ≤ −3 → 0, ≥ 3 → the 3 value). */
export function pushUnbalance(level) {
const l = Math.round(fin(level, -99));
return l <= -3 ? 0 : PUSH_UNBALANCE[Math.min(3, l)];
}
/** 失衡 of a pull of 受力等级 `level`: its force window (PULL_UNBALANCE, 0.5 s below −1), none at ≤ −3 (force 0). */
export function pullUnbalance(level) {
const l = Math.round(fin(level, -99));
return l <= -3 ? 0 : l < -1 ? PULL_UNBALANCE_WEAK : PULL_UNBALANCE;
}
export class BattleDisplacement {
/**
* 受力等级 of enemy `e` under a push / pull of 力度 `force` (微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3, 大力+1 4, 特大力 5):
@@ -39,13 +55,13 @@ export class BattleDisplacement {
* Returns the tiles moved.
*/
push(e, force, { from = null, dir = null, fixed = false, fixedAngle = false, inward = false, effect = false } = {}) {
if (!this._displaceable(e)) return 0;
if (!this._displaceable(e)) { this._staticForce(e, force, false); return 0; }
let level = this.forceLevel(e, force);
const fx0 = fin(from?.x, e.x), fy0 = fin(from?.y, e.y);
const vx = e.x - fx0, vy = e.y - fy0, d = Math.hypot(vx, vy);
const vx = e.x - fx0, vy = e.y - fy0, d = hypot(vx, vy);
let ux = 0, uy = 0;
const dirX = dir ? fin(dir.x, 0) : 0, dirY = dir ? fin(dir.y, 0) : 0;
const dl = Math.hypot(dirX, dirY);
const dl = hypot(dirX, dirY);
if (dl > 0) {
ux = dirX / dl; uy = dirY / dl;
if (from && !fixed && (d < PUSH_DIRECTIONAL_MIN_DIST || (!fixedAngle && vx * ux + vy * uy < d * Math.SQRT1_2))) {
@@ -57,7 +73,12 @@ export class BattleDisplacement {
else return 0;
let dist = pushTiles(level, effect);
if (inward && !(dl > 0)) { ux = -ux; uy = -uy; dist = Math.min(dist, Math.max(0, d - PULL_STOP_RADIUS)); }
return this.displace(e, { x: ux, y: uy }, dist);
// 失衡 for the row's 位移时间 — also when the 特效 column or a wall shortens the slide [ASSUMED for the wall]; a slide a
// wall stops at the first step gets the 0.1 s floor, like a body that cannot move [ASSUMED: 碰撞、停止 is 待补充]
const hold = pushUnbalance(level);
const moved = this.displace(e, { x: ux, y: uy }, dist, { dur: hold });
if (hold > 0) this._unbalance(e, moved > 0 ? hold : UNBALANCE_MIN);
return moved;
}
/**
@@ -67,12 +88,17 @@ export class BattleDisplacement {
* the official 拉力起点 in front of an operator. Returns the tiles moved.
*/
pull(e, force, { to, center = null, stop = PULL_STOP_RADIUS } = {}) {
if (!this._displaceable(e) || !to) return 0;
if (!to) return 0;
// an enemy the puller itself blocks already stands in front of it (at contact) [ASSUMED: no pull, no unblocking]
if (center && center.side === 'ally' && e.blockedBy === center) return 0;
if (e && center && center.side === 'ally' && e.blockedBy === center) return 0;
if (!this._displaceable(e)) { this._staticForce(e, force, true); return 0; }
const level = this.forceLevel(e, force);
// 失衡 for the force window, whatever the travel: the 急停 zeroes the movement, the state lasts to the window's end
const hold = pullUnbalance(level);
if (!(hold > 0)) return 0;
const tx = fin(to.x, e.x), ty = fin(to.y, e.y);
const dx = tx - e.x, dy = ty - e.y, d0 = Math.hypot(dx, dy);
if (!(d0 > 1e-6)) return 0;
const dx = tx - e.x, dy = ty - e.y, d0 = hypot(dx, dy);
if (!(d0 > 1e-6)) { this._unbalance(e, hold); return 0; }
const ux = dx / d0, uy = dy / d0;
// travel until inside the stop circle around `center` (smaller root of |e + t·u − c| = stop), else up to `to`
let full = d0;
@@ -83,9 +109,10 @@ export class BattleDisplacement {
const disc = wu * wu - w2 + r * r;
if (disc >= 0) { const t = -wu - Math.sqrt(disc); if (t >= 0) full = Math.min(full, t); }
}
const level = this.forceLevel(e, force);
const dist = level >= 0 ? full : level === -1 ? Math.min(full, PULL_WEAK_SHARE * d0) : level === -2 ? Math.min(full, PULL_CRAWL) : 0;
return dist > 1e-6 ? this.displace(e, { x: ux, y: uy }, dist) : 0;
const dist = level >= 0 ? full : level === -1 ? Math.min(full, PULL_WEAK_SHARE * d0) : Math.min(full, PULL_CRAWL);
const moved = dist > 1e-6 ? this.displace(e, { x: ux, y: uy }, dist, { dur: hold }) : 0;
this._unbalance(e, hold);
return moved;
}
/** Official distance (tiles) a push of 力度 `force` would move `e` on open ground (0 when it cannot be displaced). */
@@ -106,12 +133,43 @@ export class BattleDisplacement {
* 失衡状态 … 但物理层面上无法产生任何速度或移动" — every air unit of the mode except “炎佑”, plus the boss 昆图斯 (build-data
* STATIC_BODIES; player report after 0.1.0, "飞机可以被薄绿的技能拉走"). A skill that reaches it still hits it (its targeting
* is the skill's own: 锏 S3, 薄绿, the 钩索师 …); only the movement is 0, so distance-based effects (drag damage, 见行者 S2's
* wall stun) come to nothing. [ASSUMED] the 0.1 s 失衡硬直 a 静态刚体 still gets is not modelled (no displacement models it).
* wall stun) come to nothing — but a force > 0 still puts it in the 失衡 state for its 0.1 s floor (_staticForce).
*/
_displaceable(e) {
return !!(e && e.alive && e.side === 'enemy' && !e.isBoss && !e.s.flags.noDisplace && !(e.def && e.def.staticBody));
}
/**
* A push (`pull` false) / pull of 力度 `force` on a 静态刚体 (data `staticBody`; not 失衡免疫 `noDisplace` — PRTS 特殊机制
* 静态刚体: unlike 失衡免疫 it "可以进入失衡状态并启用物理,但物理层面上无法产生任何速度或移动", leaving it 「在失去施力后立刻」 with
* 「0.1 秒保底持续时间」): no movement, the state as long as the force acts — a push's is instant (推与拉 「作用时间:瞬间」) so
* the UNBALANCE_MIN floor, a pull's is its window (pullUnbalance: 1 s, 0.5 s below −1); a force > 0 (受力等级 ≥ −2) only.
* Leaders (the boss pool) stay out.
*/
_staticForce(e, force, pull = false) {
if (!(e && e.alive && e.side === 'enemy' && !e.isBoss && !e.s.flags.noDisplace && e.def && e.def.staticBody)) return;
const level = this.forceLevel(e, force);
if (level >= -2) this._unbalance(e, pull ? Math.max(UNBALANCE_MIN, pullUnbalance(level)) : UNBALANCE_MIN);
}
/**
* Put enemy `e` in the 失衡 (UNBALANCE) state for `dur` game seconds from now (a running one is kept when it lasts longer):
* a state machine, not a status (PRTS 异常效果: no 阻止攻击 status — no stun, no immunity, SP untouched) — ai.js
* updateEnemy reads `unbalanceUntil`: the enemy neither walks (恐惧 / 诱导 included) nor starts a normal attack, a swing
* short of its frame is cut; checks that ignore the state (水遁忍者's damage, content abilities) go on. The state machine
* switch ends the attack clip it stood for (PRTS 状态机: the states are exclusive — UNBALANCE, then DEFAULT → MOVE). It
* ends with its time, or at once with 浮空 (术语释义 浮空 「触发浮空时清除受到的推/拉力…无法陷入失衡」, ai.js), a 重生 or a route
* APPEAR (forced state switches). No skill meanwhile either (失衡免疫 「…使用技能」): content skills, blinks and scripted moves
* check `enemies/helpers.js unbalancedNow` (docs/SIM.md lists what deliberately keeps running).
*/
_unbalance(e, dur) {
if (!(dur > 0) || !e || !e.alive) return;
const until = this.time + dur;
if (!(e.unbalanceUntil >= until)) e.unbalanceUntil = until;
this._cutAttackStand(e);
e.atkStandUntil = -Infinity;
}
/**
* Move an enemy `distance` tiles along `dir` = {x, y} (normalised internally) over passable tiles — the raw mover of
* push() / pull(), which apply the official 力度 − 重量 rules (content uses those; the old `force` option is gone).
@@ -119,10 +177,10 @@ export class BattleDisplacement {
* ⇒ no movement (_displaceable). The tiles it may cross follow its movement (`motion`): a hovering enemy walks the
* ground, so it stays on ground-passable tiles.
*/
displace(e, dir, distance) {
displace(e, dir, distance, { dur = 0 } = {}) {
if (!this._displaceable(e) || !dir) return 0;
const dxv = fin(dir.x, 0), dyv = fin(dir.y, 0);
const len = Math.hypot(dxv, dyv);
const len = hypot(dxv, dyv);
if (!(len > 0)) return 0;
const eff = Math.min(fin(distance, 0), 2 * COLS);
if (!(eff > 0)) return 0;
@@ -140,9 +198,11 @@ export class BattleDisplacement {
if (moved > 0) {
this._unblock(e);
// 失衡 ends the attack clip it stood for (PRTS 状态机: the states are exclusive — UNBALANCE, then DEFAULT → MOVE)
this._cutAttackStand(e);
e.atkStandUntil = -Infinity;
if (e.route) e.route.pts = null;
this.fx('displace', { x: e.x, y: e.y, id: e.id });
// `dur` (game s): the 失衡 the push / pull gives — the client's slide takes that long (render/units.js slideTo)
this.fx('displace', dur > 0 ? { x: e.x, y: e.y, id: e.id, dur } : { x: e.x, y: e.y, id: e.id });
}
return moved;
}
+45 -3
View File
@@ -4,7 +4,7 @@
import { EVENT_BUFFER_CAP } from '../constants.js';
import { elementView } from '../damage.js';
import { unitInfo, snapshotUnits } from '../snapshot.js';
import { unitInfo, snapshotUnits, ammoView, wolfView, negView } from '../snapshot.js';
export class BattleEvents {
fx(kind, params = {}) {
@@ -32,12 +32,31 @@ export class BattleEvents {
return ev;
}
/**
* Record that an enemy's attack recovery (atkStandUntil) is cut or ignored now — snapshot `standCut`, display metadata
* (render/interp.js); never changes the attack's timing or state.
*/
_cutAttackStand(unit) {
if (unit.side === 'enemy' && Number.isFinite(unit.atkStandUntil) && unit.atkStandUntil > this.time) {
unit.atkStandCutAt = this.time;
}
}
/**
* Compact full snapshot of this field (DESIGN §8.2 b.snap), plus (only when non-empty):
* down: [[id, respawnAt, respawnTime, state, row, col]] — operators that left the field waiting to redeploy (isDown): the
* game time their respawn timer ends, its length (s), constants.js DOWN_STATE and the tile they lie on (and
* come back on: _layBody — where they fell, or their home);
* elem: [[id, element, fill, cooldownEnd, cooldown]] — the element gauge each unit shows (damage.js elementView).
* elem: [[id, element, fill, cooldownEnd, cooldown]] — the element gauge each unit shows (damage.js elementView);
* ammo: [[id, rounds left, rounds in the magazine]] — a running ammo skill, whole rounds (snapshot.js ammoView): the segmented bar;
* wolves: [[id, 狼影 left, the talent's maximum]] — 伺夜's 狼群 (snapshot.js wolfView): the pips under the HP bar;
* neg: [[id, fill]] — the share of its cap a negative-HP pool holds (snapshot.js negView; 斩业星熊's 我执): the red bar;
* stand: [[id, until]] — when each enemy's attack recovery ends (atkStandUntil; alive, deployed, visible, not
* feared or stunned) — display metadata (render/interp.js holds the position until then);
* standCut: [[id, at]] — the latest time each listed enemy's recovery was cut or ignored (_cutAttackStand), the
* dying ones in their death window included.
* ammo / wolves / neg / stand / standCut are display only: no sim state reads them, and the nine-field unit tuples are
* unchanged.
*/
snapshot() {
const snap = {
@@ -47,6 +66,9 @@ export class BattleEvents {
dp: this.players.length ? Math.floor(this.players[0].dp) : 0,
killed: this.killed,
total: this.total,
// the HUD capsule's numerator (DESIGN §14): the field's own scheduled enemies that are 已解决 (down or leaked).
// `killed` above counts every counted knock-out (runtime splits / summons too) and may exceed `total`.
resolved: this.resolved,
};
if (this.players.length > 1) {
snap.dps = {};
@@ -60,13 +82,33 @@ export class BattleEvents {
(down || (down = [])).push([u.id, r2(u.respawnAt), r2(Math.max(0, u.respawnAt - u.deathAt)), this._downState(u), ...this.restTile(u)]);
}
if (down) snap.down = down;
let elem = null;
let elem = null, ammo = null, wolves = null, neg = null, stand = null, standCut = null;
let listed = null; // the ids in snap.units, built for the first enemy with a cut to report
for (const u of this.units) {
if (u.side === 'enemy' && u.atkStandCutAt >= 0) {
const cutAt = Math.round(u.atkStandCutAt * 1000) / 1000;
if (cutAt <= snap.t && (listed || (listed = new Set(snap.units.map((x) => x[0])))).has(u.id)) (standCut || (standCut = [])).push([u.id, cutAt]);
}
if (!u.alive || !u.deployed || u.hidden) continue;
const until = Math.round(u.atkStandUntil * 1000) / 1000;
if (u.side === 'enemy' && !u.s.flags.fear && !u.s.flags.stun && Number.isFinite(until) && until > snap.t) {
(stand || (stand = [])).push([u.id, until]);
}
const v = elementView(u, this.time);
if (v) (elem || (elem = [])).push([u.id, v[0], v[1], v[2], v[3]]);
const am = ammoView(u);
if (am) (ammo || (ammo = [])).push([u.id, am[0], am[1]]);
const wv = wolfView(u);
if (wv) (wolves || (wolves = [])).push([u.id, wv[0], wv[1]]);
const ng = negView(u);
if (ng) (neg || (neg = [])).push([u.id, ng]);
}
if (elem) snap.elem = elem;
if (ammo) snap.ammo = ammo;
if (wolves) snap.wolves = wolves;
if (neg) snap.neg = neg;
if (stand) snap.stand = stand;
if (standCut) snap.standCut = standCut;
return snap;
}
+5 -2
View File
@@ -121,7 +121,7 @@ export class BattleLifecycle {
this.finished = true;
this.reason = reason === 'timeout' ? 'timeout' : 'forced';
this._endReq = null;
try { this._result = this._buildResult(); } catch { this._result = { time: this.time, reason: this.reason, perPlayer: {}, killed: this.killed, total: this.total, errors: this.errorCount }; }
try { this._result = this._buildResult(); } catch { this._result = { time: this.time, reason: this.reason, perPlayer: {}, killed: this.killed, total: this.total, resolved: this.resolved, errors: this.errorCount }; }
this.projectiles.clear();
}
@@ -209,6 +209,9 @@ export class BattleLifecycle {
for (const ps of this.players) {
const pp = this._perPlayer[ps.playerId];
pp.perfect = !pp.leaked.some((l) => l.counted !== false);
// the HUD capsule's numerator of this field (DESIGN §14): this player's own scheduled enemies resolved — knocked
// down or leaked (`counted` keeps the LP / 完美作战 reading, runtime splits and summons included)
pp.resolved = Math.min(pp.total, pp.killedInTotal + pp.leakedInTotal);
// the operators and the board's summon pieces (a board uid): 联防 carries an operator's HP ratio and SP, a summon's
// SP only (match/unite.js). `sp` is the official 技力 — stored charges included (PRTS 技能 "可充能X次…当前技力上限等于该
// 技能技力需求的X倍"); a running skill spent its SP at activation, so it reports what was left (0 for one charge).
@@ -226,7 +229,7 @@ export class BattleLifecycle {
}));
perPlayer[ps.playerId] = pp;
}
const res = { time: Math.round(this.time * 1000) / 1000, reason: this.reason, perPlayer, killed: this.killed, total: this.total, errors: this.errorCount };
const res = { time: Math.round(this.time * 1000) / 1000, reason: this.reason, perPlayer, killed: this.killed, total: this.total, resolved: this.resolved, errors: this.errorCount };
if (this.unspawned && this.unspawned.length) res.unspawned = this.unspawned;
if (this.sharedBoss) res.bossHpLeft = Math.max(0, this.sharedBoss.hp);
return res;
+23 -3
View File
@@ -9,7 +9,9 @@ import { normDir, mirrorDir } from '../dir.js';
import { SkillRuntime } from '../skills.js';
import { resolveProfile } from '../professions.js';
import { normalizeToken } from '../simdata.js';
import { isPotential, isCultivate } from '../../../shared/potential.js';
import { setupUnitKit } from '../content/index.js';
import { installTraitAttackSpeed } from '../content/traitMods.js';
import { clone } from './util.js';
export class BattlePlayers {
@@ -39,8 +41,11 @@ export class BattlePlayers {
};
this.players.push(ps);
this._perPlayer[ps.playerId] = {
killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, coins: 0,
damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [],
// `killed` / `total` / `leaked` / `perfect` keep the official `counted` reading (DESIGN §5.1); `killedInTotal` /
// `leakedInTotal` are the HUD capsule's counters of this player's own field (only enemies the round scheduled:
// spawns.js `_queueSpawn` / `inTotal`, deploy.js), `resolved` is derived from them at result time.
killed: 0, total: 0, leaked: [], perfect: true, killedInTotal: 0, leakedInTotal: 0,
layerGains: {}, coins: 0, damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [],
};
const late = [];
for (const u of p.units ?? []) {
@@ -141,10 +146,12 @@ export class BattlePlayers {
// the unit's own loadout (DESIGN §16): an entry without loadout fields is the DEFAULT — never another player's
// choice for the same chess id in a multi-player field (the per-battle data view maps id-only lookups); `standIn:
// true` fields the chess as its 补位 stand-in (simdata getStandIn: the stand-in's body, the chess's identity); `diy`
// fills a 自选 slot with its pick (simdata getDiy: the slot's identity, the operator's body, skill and module)
// fills a 自选 slot with its pick (simdata getDiy: the slot's identity, the operator's body, skill and module);
// `potential` (潜能 1–6, 0.2.2) composes the def at it (never a stand-in's)
const lo = { skillIndex: inp.skillIndex ?? null, moduleId: inp.moduleId ?? null };
if (inp.standIn === true) lo.standIn = true;
if (inp.diy && typeof inp.diy === 'object') lo.diy = inp.diy;
if (inp.standIn !== true && isPotential(inp.potential)) lo.potential = inp.potential;
const def = this.data.getChess(inp.chessId, lo);
if (!def) { this.log(`unknown chess ${inp.chessId}${lo.diy ? ' (illegal 自选 pick)' : ''}`); return null; }
// a DIY slot has no body of its own (甄选干员): it fights only as a 自选 piece (its `diy` pick)
@@ -152,6 +159,12 @@ export class BattlePlayers {
const u = this._makeAlly(ps, def, 'op', r, c, { uid: inp.uid, dir });
u.items = [...(inp.items ?? [])];
u.carry = inp.carryState ?? null;
// 练度 (自持有, 0.2.2): the owned operator's ×ATK / ×DEF / ×max HP (units.js Πmul) — never a 补位 stand-in's nor a
// prototype 自选 pick's (another character than the one the player owns)
if (isCultivate(inp.cultivate) && !def.standInFor && !def.raw?.diyProto) {
const mul = typeof this.data.cultivateMul === 'function' ? this.data.cultivateMul(inp.cultivate) : null;
if (mul) { u.cultivate = inp.cultivate; u.cultMul = Object.freeze({ ...mul }); u.markDirty(); }
}
return u;
}
@@ -217,6 +230,13 @@ export class BattlePlayers {
if (t && typeof t.install === 'function') this._safe(() => t.install(this, u), 'talent.install', u);
}
if (typeof u.kit.install === 'function') this._safe(() => u.kit.install(this, u), 'kit.install', u);
// trait lines the ENGINE owns for every operator (content/traitMods.js: the module attack-speed riders whose
// condition is a pure function of the field — 「攻击范围内存在N名及以上敌人时攻击速度+X」). Runs after kit.install:
// a kit that implements its own line for this trait is never double-counted — traitMods.js refuses every condition
// shape a kit already owns (`reason: 'kit'`), and the kits whose line it DOES take over (the two REA-Y ones) had
// their hand-written copy deleted in the same change. Calling it before kit.install would let a kit's own buff and
// this rule both land on the same unit.
this._safe(() => installTraitAttackSpeed(this, u), 'traitMods.attackSpeed', u);
return u.kit;
}
}
+19 -4
View File
@@ -45,19 +45,34 @@ export class BattleQueries {
/**
* Ally units an ally's attack selects like an enemy — the "ally targets" content registers (setAllyTarget): 白铁's
* 铁钳号·原型机, a summon of the enemy camp that "可被我方干员攻击但不受伤害" (PRTS 铁钳号·原型机 备注 "该召唤物阵营为敌方";
* kits/ops/op-ironmn.js). Those standing on one of `keys`, alive and deployed — never `attacker` itself, and an ally target
* selects none. ai.js acquireTargets appends them after the enemies (their 嘲讽等级 −2 puts them last). [] at once
* while none is registered, so every other battle runs exactly as before.
* kits/ops/op-ironmn.js). Those standing on one of `keys` (tile keys, or a Set of them), alive and deployed — never
* `attacker` itself, and an ally target selects none. ai.js acquireTargets appends them after the enemies (their 嘲讽等级
* −2 puts them last); a skill that acts on them counts them like an enemy for its automatic start (skills.js
* `_allyTargetIn` / `allyTargetsOk`). [] at
* once while none is registered, so every other battle runs exactly as before.
*/
allyTargetsInKeys(keys, attacker) {
const set = this._allyTargets;
if (!set || !set.size || !keys || !attacker || set.has(attacker)) return [];
const ks = keys === attacker.rangeKeys && attacker.rangeKeySet ? attacker.rangeKeySet : new Set(keys);
const ks = keys instanceof Set ? keys : (keys === attacker.rangeKeys && attacker.rangeKeySet ? attacker.rangeKeySet : new Set(keys));
const out = [];
for (const a of set) if (a.alive && a.deployed && !a.hidden && ks.has(a.tileR * COLS + a.tileC)) out.push(a);
return out;
}
/**
* The registered ally targets within `r` of (x, y) (centre distance) — the radius form of allyTargetsInKeys, for a kit
* whose area selection takes them too (a skill flagged `allyTargets`, skills.js allyTargetsOk). [] while none is registered.
*/
allyTargetsInRadius(x, y, r, attacker) {
const set = this._allyTargets;
if (!set || !set.size || !attacker || set.has(attacker)) return [];
const r2 = r * r + 1e-9;
const out = [];
for (const a of set) if (a.alive && a.deployed && !a.hidden && (a.x - x) * (a.x - x) + (a.y - y) * (a.y - y) <= r2) out.push(a);
return out;
}
/** Register (`on`) or drop an ally target (allyTargetsInKeys); one off the field is skipped while registered. */
setAllyTarget(unit, on = true) {
if (!unit || unit.side !== 'ally') return false;
+16 -4
View File
@@ -1,4 +1,5 @@
// server/sim/battle/spawns.js — Battle methods: enemies: the spawn queue (counted in `total` up front), routes and the
// server/sim/battle/spawns.js — Battle methods: enemies: the spawn queue (the stage's own enemies: `inTotal`, counted in
// `total` up front — the HUD capsule's numerator/denominator, deploy.js killedInTotal / leakedInTotal), routes and the
// owner of a tile, spawnEnemy, and the enemies on the field — hidden ones, the shared boss pool sync, the field clamp
// and the list compaction.
// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle).
@@ -31,7 +32,11 @@ export class BattleSpawns {
ownerPlayerId: s.ownerPlayerId ?? null, pos: s.pos ?? null, seq: ++this._spawnSeq, countInTotal: s.countInTotal,
};
p.counted = p.countInTotal ?? (!(def && def.notCountInTotal) && p.tag !== 'boss' && p.tag !== 'part');
if (precount && p.counted) {
// inTotal: the enemies THIS STAGE scheduled that count — the HUD capsule's own set (DESIGN §14 "顶栏胶囊"). A
// runtime spawn (a split child, a summon, a part, a form change) is never in it, so the capsule's denominator stays
// the stage's own enemies while `counted` (LP, 破坏完美作战 and the kill counters) keeps counting them all.
p.inTotal = !!p.counted;
if (precount && p.inTotal) {
this.total++;
const owner = p.ownerPlayerId ?? this._ownerForTile(p.pos ?? this._routeFor(p.routeIndex, p.route)?.start);
const pp = this._pp(owner);
@@ -73,7 +78,7 @@ export class BattleSpawns {
/**
* Spawn an enemy now. opts: { routeIndex, route, pos:[r,c], mods:{hpMul,atkMul,defMul,resMul,speedMul}, tag,
* sourcePlayerId, ownerPlayerId, bounty:{coins,ownerPlayerId}, countInTotal }
* sourcePlayerId, ownerPlayerId, bounty:{coins,ownerPlayerId}, countInTotal, inTotal }
*/
spawnEnemy(enemyKey, opts = {}) {
if (this.enemies.length >= MAX_ALIVE_ENEMIES && this.aliveEnemies().length >= MAX_ALIVE_ENEMIES) {
@@ -132,6 +137,8 @@ export class BattleSpawns {
e.atkCd = 0;
e.pauseUntil = -Infinity; // content holds (暴鸰's drop)
e.atkStandUntil = -Infinity; // standing for its attack clip (ai.js attackStand)
e.unbalanceUntil = -Infinity; // 失衡 (UNBALANCE) after a push / pull (battle/displacement.js _unbalance, ai.js)
e.atkStandCutAt = -Infinity; // display metadata: when its stand was last cut or ignored (snapshot standCut)
e.swing = false; // a normal attack swung, its damage frame not reached yet (ai.js enemyAttack)
// every enemy profile starts with the same fields (stable object shapes keep the hot loop's property reads fast);
// `dmgType` null = the data's (content may arm a data-unarmed enemy: ai.js enemyAttack); `blockFree` = its 索敌不受阻挡
@@ -143,7 +150,12 @@ export class BattleSpawns {
if (end) e.route.legs.push({ t: 'move', r: end[0], c: end[1], final: true });
}
e.counted = opts.countInTotal ?? (!def.notCountInTotal && e.tag !== 'boss' && e.tag !== 'part');
if (e.counted && !opts._precounted) {
// inTotal (the HUD capsule's own counter, DESIGN §14): only an enemy the stage scheduled (`_queueSpawn`, which
// pre-counts it) — or, for future content, an explicit `opts.inTotal: true` — and that counts. Everything spawned
// while the battle runs (a split child, a summon — bosses included —, a part, a 变身 copy) stays out of the capsule's
// numerator and denominator, while `counted` (LP, 破坏完美作战, `killed`) keeps counting it as before.
e.inTotal = opts.inTotal === true && e.counted;
if (e.inTotal && !opts._precounted) {
this.total++;
const pp = this._pp(e.ownerId);
if (pp) pp.total++;
+25 -9
View File
@@ -274,23 +274,39 @@ export class BattleStatus {
}
/**
* "同名效果取最高": keep the strongest value; a weaker one that outlasts it resumes afterwards (buff.data.tail). A
* "同名效果取最高": keep the strongest value, then resume weaker effects in order of strength at their original expiry times.
* `buff.data.tail` links the waiting effects, with decreasing strength and increasing expiry times. A
* `plain` template (applyStrongest) is an ordinary invisible buff, not a status. `stackAs` (finite): the strength this
* application competes with instead of its value (applyStatus opts.stackAs); kept in `data.stackAs` / the tail.
*/
_applyValuedStatus(target, key, tpl, duration, value, source, stackAs = null) {
const as = Number.isFinite(stackAs) ? stackAs : null;
const strength = (v, s = null) => Math.abs(Number.isFinite(s) ? s : Number.isFinite(v) ? v : tpl.valued);
// (no stackAs: the very objects of before — data { value, tail }, tails { value, until })
const entry = (v, s, extra) => (Number.isFinite(s) ? { value: v, stackAs: s, ...extra } : { value: v, ...extra });
const after = (tail, until) => {
while (tail && tail.until <= until) tail = tail.tail;
return tail || null;
};
const insertTail = (tail, incoming) => {
if (!tail) return incoming;
const diff = strength(incoming.value, incoming.stackAs) - strength(tail.value, tail.stackAs);
if (diff > 1e-12) return { ...incoming, tail: after(tail, incoming.until) };
if (diff < -1e-12) {
if (incoming.until <= tail.until) return tail;
return { ...tail, tail: insertTail(tail.tail, incoming) };
}
// Equal-priority waiting effects keep the longest-lived application's value, including its stackAs.
const kept = incoming.until > tail.until ? incoming : tail;
return { ...kept, tail: after(tail.tail, kept.until) };
};
const make = (v, dur, tail, s = null) => ({
...(tpl.buff || null), // extra buff fields of the status (抵抗: the 麻痹 decay tick)
key, duration: dur, refresh: 'replace', mods: tpl.mods(v), flags: tpl.flags || null, status: tpl.plain ? null : key,
visible: !tpl.plain, source,
data: entry(v, s, { tail }),
onExpire: ({ battle, unit, buff }) => {
const t = buff.data.tail;
if (t && t.until - battle.time > 1e-6 && unit.alive) battle.addBuff(unit, make(t.value, t.until - battle.time, null, t.stackAs));
const t = after(buff.data.tail, battle.time + 1e-6);
if (t && unit.alive) battle.addBuff(unit, make(t.value, t.until - battle.time, t.tail || null, t.stackAs));
},
});
const old = target.buffs.find((b) => b.key === key && (tpl.plain || b.status === key));
@@ -299,17 +315,17 @@ export class BattleStatus {
const oldAs = old.data && Number.isFinite(old.data.stackAs) ? old.data.stackAs : null;
const oldEnd = this.time + old.timeLeft, newEnd = this.time + duration;
const oldTail = old.data && old.data.tail;
const longerTail = (a, b) => (!a ? b : !b ? a : (b.until > a.until ? b : a));
if (strength(value, as) > strength(oldV, oldAs) + 1e-12) {
// stronger: takes over now; the weaker old one (or its tail) resumes if it lasts longer
const tail = longerTail(oldEnd > newEnd ? entry(oldV, oldAs, { until: oldEnd }) : null, oldTail && oldTail.until > newEnd ? oldTail : null);
// Stronger: take over now, retaining every old effect that can still become active afterwards.
const tail = after(entry(oldV, oldAs, { until: oldEnd, tail: oldTail }), newEnd);
this.addBuff(target, make(value, duration, tail, as));
} else if (strength(value, as) < strength(oldV, oldAs) - 1e-12) {
// weaker: never overrides; remembered as the tail when it outlasts the running one
if (newEnd > oldEnd && (!oldTail || newEnd > oldTail.until)) old.data = { ...old.data, value: oldV, tail: entry(value, as, { until: newEnd }) };
// Weaker: insert among the waiting effects instead of replacing the one with the latest expiry.
if (newEnd > oldEnd) old.data = { ...old.data, value: oldV, tail: insertTail(oldTail, entry(value, as, { until: newEnd })) };
} else if (duration > old.timeLeft) {
old.timeLeft = duration;
old.duration = Math.max(old.duration, duration);
old.data.tail = after(oldTail, newEnd);
}
}
+3 -2
View File
@@ -24,6 +24,7 @@
// `hitRect`).
import { ROWS, COLS } from './constants.js';
import { hypot } from './detmath.js';
/**
* Validated hit area of a data record (`{ w, h, dx, dy }`, tiles) or null for a regular (point) unit.
@@ -83,10 +84,10 @@ export function bodyInKeys(u, keys) {
/** Distance (tiles) from point (x, y) to the unit's body: its position, or its rectangle (0 inside). */
export function bodyDist(u, x, y) {
const R = hitRect(u);
if (!R) return Math.hypot(u.x - x, u.y - y);
if (!R) return hypot(u.x - x, u.y - y);
const dx = x < R.x0 ? R.x0 - x : x > R.x1 ? x - R.x1 : 0;
const dy = y < R.y0 ? R.y0 - y : y > R.y1 ? y - R.y1 : 0;
return Math.hypot(dx, dy);
return hypot(dx, dy);
}
/** Is the unit's body within `r` tiles of point (x, y)? (the same ≤ r + 1e-9 test as Battle.enemiesInRadius) */
+4 -3
View File
@@ -8,6 +8,7 @@
// Additive mod keys scale with stacks (value × stacks); *Mul keys multiply (value ^ stacks).
import { COLD_ASPD, COLD_FREEZE_DURATION, FREEZE_RES_DOWN, RESIST_DEFAULT, RESIST_PALSY_DECAY } from './constants.js';
import { powi } from './detmath.js';
/** 抵抗: "麻痹等状态每5秒流失1层" — tick of the resist buff. */
function resistPalsyDecay({ battle, unit }) {
@@ -200,12 +201,12 @@ export function aggregateMods(buffs) {
const v = m[k];
if (typeof v !== 'number' || !Number.isFinite(v)) continue;
if (k === 'shield') continue;
if (k.endsWith('Mul')) mul[k] = (mul[k] ?? 1) * (st === 1 ? v : Math.pow(v, st));
if (k.endsWith('Mul')) mul[k] = (mul[k] ?? 1) * (st === 1 ? v : powi(v, st));
else if ((k === 'dodgePhys' || k === 'dodgeArts') && v > 0) {
const p = Math.min(1, v);
const d = dodge[k];
if (!d) dodge[k] = { n: st, p, miss: Math.pow(1 - p, st) };
else { d.n += st; d.miss *= Math.pow(1 - p, st); }
if (!d) dodge[k] = { n: st, p, miss: powi(1 - p, st) };
else { d.n += st; d.miss *= powi(1 - p, st); }
} else {
add[k] = (add[k] ?? 0) + v * st;
if (k === 'rangeExtend' && b.persist && b.duration === Infinity) permRangeExtend += v * st;
+16
View File
@@ -128,6 +128,22 @@ export const PULL_CRAWL = 0.03;
export const PULL_ORIGIN = 0.5;
export const PULL_STOP_RADIUS = 0.6708;
export const PUSH_DIRECTIONAL_MIN_DIST = 0.25;
/**
* 失衡 (UNBALANCE) — the state machine a force > 0 puts an enemy in (PRTS 失衡位移机制 「当一名敌方单位受到一个任意来源的力,
* 并且受力大小 > 0 时,该敌方单位将进入失衡(UNBALANCE)状态机」; 异常效果图鉴/失衡免疫 「失衡期间无法自主移动、发动攻击、使用技能」 —
* not an abnormal status: 异常效果 「失衡…不属于异常效果,它是一种状态机」, so it is no stun). Its length in game seconds:
* PUSH_UNBALANCE = a push by 受力等级 — PRTS 游戏数据基础 推力-位移近似对应表 「位移时间」 (whole frames at 30 fps: 6, 12, 24,
* 27, 32, 35; the 0.1 s floor is inside them); PULL_UNBALANCE / PULL_UNBALANCE_WEAK = a pull's force window — 推与拉 §拉力
* 「作用时间默认为 1 s;若本次受力等级 < −1,作用时间改为 0.5 s」, the state lasting to its end after the 急停 too (「目标将仍保持
* 失衡状态至拉力作用时间结束为止」); UNBALANCE_MIN = the 失衡硬直 floor (「立刻拥有 0.1 s 的“失衡硬直”。此期间无法解除失衡状态机
* ——哪怕已经没有被移动或者受力」) — what a 静态刚体 hit by a push gets (特殊机制 静态刚体 「失衡状态拥有 0.1 秒保底持续时间」; a pulled
* one stays for the pull's window, its force lasting that long).
* PR #392 by @xcdoge brought the state; its 0.6387·√tiles was derived from the page's μ = 0.5 单位假设, not printed.
*/
export const PUSH_UNBALANCE = Object.freeze({ '-2': 0.2, '-1': 0.4, 0: 0.8, 1: 0.9, 2: 32 / 30, 3: 35 / 30 });
export const PULL_UNBALANCE = 1;
export const PULL_UNBALANCE_WEAK = 0.5;
export const UNBALANCE_MIN = 0.1;
/**
* Fallback freeze when a second 寒冷 lands and neither the remaining cold nor the incoming one has a duration
+4 -3
View File
@@ -16,8 +16,9 @@
// act1autochess_band13_buff 克莱门莎 崇高牺牲 a <bond_id> operator knocked down ⇒ +its tier (等阶) <bond_id> layers
// (bond_add_type by_charlevel; no "已激活" in the text ⇒ requireActive false)
// act1autochess_band16_buff 大帝 加急调派 "每次部署后再部署时间减少50%": every deployment of an operator stacks one
// redeploy ×(1 + respawn_time) for the rest of the battle [ASSUMED cumulative:
// "每次" — the first redeploy is −50 % under either reading]
// redeploy ×(1 + respawn_time) for the rest of the battle, with no cap (PRTS
// 卫戍协议:盟约 下半 / PRTS盟约记录 备注 "※该策略效果可无限叠加"; a 20-stack cap
// until 0.2.2 — GitHub #328, PR #329)
// act1autochess_band17_buff 桑葚 药枚实验 at combat start the player's units on the front-most (最右边) column: each
// attack has `prob` to gain 1 shield layer (max 1)
// act1autochess_band18_buff 休谟斯 回收利用 a ground (地面) operator's skill ends ⇒ a random operator on its 4
@@ -106,7 +107,7 @@ const BY_KEY = {
battle.on('deploy', (c) => {
const u = c.unit;
if (!isOp(u) || u.ownerId !== ps.playerId) return;
battle.addBuff(u, { key, mods: { redeployMul: mul }, refresh: 'stack', stacks: 1, maxStacks: 20, persist: true, allowDead: true });
battle.addBuff(u, { key, mods: { redeployMul: mul }, refresh: 'stack', stacks: 1, maxStacks: Infinity, persist: true, allowDead: true });
});
},
+12 -4
View File
@@ -19,6 +19,7 @@
// shop: onBuy — a free pick / grant never uses it up)
// band_cost_coin_reach_cnt_gain_chess_from_pool 潘格尼尼 once coin_cnt funds spent in total: 1 chess of pool (elite 拉特兰 ≥ T4)
// round_start_bond_check_gain_layer 余 round start of `round`: exactly factioncount active bonds → +count1, else each active +count2
// — never to a bond without layers (noStack; the owner's decision of 2026-10-08)
// up_shop_add_special_goods {count,choice,pool} 凯瑟琳 every level-up: pick 1 of `count` items of pool (free)
// coin_carry_over {capital,interest,max} 坎诺特 leftover ≥ capital at round start → +min(max, ⌊left/capital⌋×interest) income
// round_start_all_player_change_enemy_2 鸭爵 global: every alive player's battle from `round` (own + teammates;
@@ -244,15 +245,22 @@ K.band_cost_coin_reach_cnt_gain_chess_from_pool = (ps) => ({
},
});
/**
* 余 【文火慢炖】 「第8回合开始时,若仅激活了1个盟约,使其增加36层;否则使所有已激活盟约增加12层」. The owner's decision of 2026-10-08
* overrides PRTS here (下半/PRTS盟约记录: layers of a bond that shows none 「仍然对其生效」): a bond without layers — bonds.json
* `noStack`: 绝技, 独行, 调和, 协防干员 — gets none. The count stays the sentence's (every active bond), so one active bond
* that is such a bond means nothing is added, and with two active bonds a layered one still gets count2. Only this
* strategy: 协防's 「使所有已激活的盟约层数」 (bonds/addon/meta.js) and 华法琳's 【重点监护】 keep adding to every bond they name.
*/
K.round_start_bond_check_gain_layer = (ps) => ({
onRoundStart(ctx) {
const p = ps[0];
if (ctx.round !== int(p.round, -1)) return;
const active = Object.entries(ctx.bonds()).filter(([, b]) => b && b.active).map(([id]) => id);
if (active.length === int(p.factioncount, 1)) {
for (const b of active) ctx.addLayers(b, int(p.count1, 0), { requireActive: true, reason: 'band' });
} else {
for (const b of active) ctx.addLayers(b, int(p.count2, 0), { requireActive: true, reason: 'band' });
const n = active.length === int(p.factioncount, 1) ? int(p.count1, 0) : int(p.count2, 0);
for (const b of active) {
if (ctx.gd.bond(b)?.noStack) continue;
ctx.addLayers(b, n, { requireActive: true, reason: 'band' });
}
},
});
+36 -9
View File
@@ -33,7 +33,9 @@
// 助力 deputShip all operators DEF +(base + per·L), redeploy time ×(1 + respawn_time)
// 突袭 raidShip member idle ≥ no_attack_duration s (or skill ready — a passive skill that is on counts, GitHub
// #49, and so does a deploy-timed skill while it runs, #109) with no enemy in range → "保留技力立即再部署"
// next to the most advanced ground enemy it can reach: on a free tile of a board of its field
// next to the most advanced ground enemy it can reach — a 隐匿 (unrevealed) ground enemy too, and one
// in its range counts for 「范围内没有敌人」 (the owner's decision of 2026-10-08 from the official game,
// GitHub #316; asleep, untargetable and flying ones stay out: raidGroundEnemy): on a free tile of a board of its field
// (Battle.onFieldBoard: its own or a teammate's on the same field — the two-helper 联防 field and a
// pair field open both halves, the owner's decision of 2026-10-07; never a boss field's hand /
// 临时整备区 rows, never the empty other half of a lone 联防 helper or a solo boss field — community
@@ -62,9 +64,9 @@
// Hooks registered only when a bond needs them. Priorities: `hit` −20 (坚守 redirect, after other modifiers had their
// say), `death` 10 (不屈, the bond slot of the revive/redeploy convention); everything else 0.
import { canTargetEnemy, extendedGrid } from '../../../targeting.js';
import { canTargetEnemy, enemyStealthed, extendedGrid } from '../../../targeting.js';
import { normDir, rotateOffset, localOrder, localBefore } from '../../../dir.js';
import { bodyKeys } from '../../../body.js';
import { bodyKeys, bodyInKeys } from '../../../body.js';
import { isHpLoss } from '../../../damage.js';
import {
num, bondRecord, buffParams, bondTier, bondLayers, isMember, isElite, isGroundOp, onField, playerOps, passiveBuff,
@@ -315,15 +317,39 @@ function raidTile(battle, u, e, reach) {
}
/**
* Jump candidates of player `pid`, in priority order: the ground enemies an operator may target (not flying,
* canTargetEnemy) — those of the player's own field (`ownerId`), the others only when it has none —, the most advanced
* first (least remaining path distance, then the earliest spawned) [ASSUMED, research 02 §3.18]. The same list for
* every member of the player (canTargetEnemy reads the enemy, not the attacker).
* A ground enemy 突袭 jumps toward, and that counts for 「范围内没有敌人」 while it stands in a member's range: one an
* operator may target (canTargetEnemy, ground), or a 隐匿 one that only its 隐匿 keeps out — the owner's decision of
* 2026-10-08 from the official game (GitHub #316 "Raid operator doesn't deploy to invisible enemy units, it does in
* official"; the bond text says 敌人 / 地面敌人). Asleep, untargetable and flying enemies stay out, as before.
*/
function raidGroundEnemy(u, e) {
if (!e || e.isFlying) return false;
if (canTargetEnemy(u, e, { canHitFly: false })) return true;
if (!e.alive || e.hidden || !e.deployed || !enemyStealthed(e)) return false;
const f = e.s.flags;
if (f.untargetable || f.sleep) return false;
const skip = u.profile && u.profile.skipEnemy;
return !(typeof skip === 'function' && skip(e));
}
/** A 隐匿 ground enemy (raidGroundEnemy) on `u`'s range: enemiesInKeys leaves 隐匿 out, so the poll checks this too. */
function raidStealthInRange(battle, u) {
const keys = u.rangeKeySet || u.rangeKeys;
if (!keys) return false;
for (const e of battle.enemies) if (e.alive && enemyStealthed(e) && raidGroundEnemy(u, e) && bodyInKeys(e, keys)) return true;
return false;
}
/**
* Jump candidates of player `pid`, in priority order: the ground enemies of raidGroundEnemy (visible, or 隐匿; not
* flying, asleep or untargetable) — those of the player's own field (`ownerId`), the others only when it has none —,
* the most advanced first (least remaining path distance, then the earliest spawned) [ASSUMED, research 02 §3.18].
* The same list for every member of the player (the checks read the enemy, not the attacker).
*/
function raidTargets(battle, u, pid) {
const own = [], other = [];
for (const e of battle.enemies) {
if (e.isFlying || !canTargetEnemy(u, e, { canHitFly: false })) continue;
if (!raidGroundEnemy(u, e)) continue;
(e.ownerId === pid ? own : other).push(e);
}
const list = own.length ? own : other;
@@ -352,7 +378,8 @@ function raidPoll(battle, st) {
const ready = !!(sk && !sk.noSkill && ((sk.ready && !(sk.active && sk.isTimed)) || deploySkillOn));
const idleOk = battle.time - since >= idle - 1e-9;
if (!(ready || idleOk)) continue;
if (battle.enemiesInKeys(u.rangeKeys || [], u, u.profile).length) continue;
// 「若范围内没有敌人」: a targetable enemy, or a 隐匿 ground one (the owner's decision of 2026-10-08, raidGroundEnemy)
if (battle.enemiesInKeys(u.rangeKeys || [], u, u.profile).length || raidStealthInRange(battle, u)) continue;
const list = (targets ??= raidTargets(battle, u, st.pid));
if (!list.length) continue;
// either trigger: raidTile only offers tiles with the target in range (without that a ready skill that finds no
+29 -19
View File
@@ -73,8 +73,9 @@ import { compileRoute } from '../ai.js';
import { normalizeRoute } from '../simdata.js';
import {
ensureInstalled, abOf, attach, T, elem, hurt, targetsNear, allTargets, byPriority, areaAllies, areaAlliesInTiles, fieldAllies,
remainingRoute, stayRoute, stepToward, setHits, hitCount, lpLoss, blinkForward, canCast, absorbArts, nthOf,
remainingRoute, stayRoute, stepToward, setHits, hitCount, lpLoss, blinkForward, canCast, unbalancedNow, absorbArts, nthOf,
} from './enemies.js';
import { hypot, powi } from '../detmath.js';
// ---------------------------------------------------------------------------------------------------------------
// constants
@@ -209,7 +210,7 @@ function rawRouteOf(b, e, tpl) {
function toGoal(b, x, y, motion = 'WALK') {
const ends = b.grid.specialTiles('end');
let best = ends[0] ?? [Math.round(y), Math.round(x)], bd = Infinity;
for (const p of ends) { const d = Math.hypot(p[0] - y, p[1] - x); if (d < bd) { bd = d; best = p; } }
for (const p of ends) { const d = hypot(p[0] - y, p[1] - x); if (d < bd) { bd = d; best = p; } }
return { motion, start: [y, x], end: best, checkpoints: [] };
}
@@ -318,7 +319,7 @@ function mirror(b, e, tpl) {
const opsOnly = (list) => list.filter((u) => u.kind === 'op');
const nearestOf = (b, e, pred) => {
let best = null, bd = Infinity;
for (const o of b.enemies) { if (!o.alive || o === e || !pred(o)) continue; const d = Math.hypot(o.x - e.x, o.y - e.y); if (d < bd) { bd = d; best = o; } }
for (const o of b.enemies) { if (!o.alive || o === e || !pred(o)) continue; const d = hypot(o.x - e.x, o.y - e.y); if (d < bd) { bd = d; best = o; } }
return best;
};
/** Apply a floor to an enemy whose data attack speed is 0 (skill-driven leaders: 铳/管/弦). */
@@ -370,7 +371,7 @@ function stunBlast(b, src, by, r, c, stun, dot, dur, kind) {
function segDist(px, py, ax, ay, bx, by) {
const dx = bx - ax, dy = by - ay, L = dx * dx + dy * dy;
const t = L > 0 ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / L)) : 0;
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
return hypot(px - (ax + t * dx), py - (ay + t * dy));
}
// ---------------------------------------------------------------------------------------------------------------
@@ -523,11 +524,12 @@ function kitBlade(ab, e, b, tpl) {
// 自缚 (moved by hand) · 不可阻挡 · 失衡免疫 (PRTS 天赋 "{{特殊机制|静态刚体}},不可阻挡、失衡免疫…" — data `staticBody` too)
b2.addBuff(e2, { key: 'boss:anchor', persist: true, flags: { noMove: true, selfBound: true, unblockable: true, noDisplace: true } });
if (BLADE_ATK_SCALE[e2.defId]) e2.profile.atkScale = BLADE_ATK_SCALE[e2.defId];
e2.profile.canTarget = (u) => !u.isFlying; // "…不可对空" (below)
},
// 范围物理伤害 — "普通攻击对攻击范围内的所有我方单位造成…物理普通伤害": a normal attack on every operator it can target
// 范围物理伤害 — "普通攻击对攻击范围内的所有我方单位造成…物理普通伤害,不可对空": a normal attack on every operator it can target
// (canTargetAlly: no 隐匿 or 迷彩 one — it is never blocked —, PRTS 选择器: a normal attack does not ignore 迷彩; until
// 0.1.2 it took them all, `ranged: false`)
before(c, b2, e2) { const l = targetsNear(b2, e2, e2.base.rangeRadius || 1.6); if (l.length) c.targets = l; },
// 0.1.2 it took them all, `ranged: false`), never a flying ally (the 炎佑 dragon — until 0.2.1 it was hit)
before(c, b2, e2) { const l = targetsNear(b2, e2, e2.base.rangeRadius || 1.6).filter((u) => !u.isFlying); if (l.length) c.targets = l; },
taken(c, b2, e2) {
if (c.amount > 0) { // 受到伤害时令假想敌:胄受到等量的无来源生命流失 (【瘫痪】; 出击模式 [ASSUMED], see PART_TRANSFER)
const L = leader(b2);
@@ -537,7 +539,7 @@ function kitBlade(ab, e, b, tpl) {
if (P.state === 'dive' && e2.alive && ++P.hits >= (bb.max_hit_cnt ?? Infinity)) shotDown(b2, e2); // 仅1次
},
tick(b2, e2, a, dt) {
if (P.state !== 'dive' || !canCast(e2)) return; // a stunned blade does not fly on
if (P.state !== 'dive' || !canCast(e2, false, b2)) return; // a stunned blade does not fly on
const t = P.target;
if (!stepToward(e2, t.c, t.r, e2.s.moveSpeed * MOVE_SCALE * dt)) return;
// arrival: 3×3 stun + DoT (无来源: credited to 胄, as the shell's), then the 初始模式 copy takes over
@@ -591,7 +593,7 @@ function kitGun(ab, e, b) {
dealt(c, b2, e2) { elem(b2, e2, c.target, 'erosion', e2.s.atk * (T(ab, '1.ep_damage_ratio') ?? 0)); }, // 攻击时附带侵蚀损伤
tick(b2, e2, a, dt) {
const ch = P.charge;
if (!ch) return;
if (!ch || unbalancedNow(b2, e2)) return; // (失衡: no scripted move meanwhile)
const t = ch.target;
const sp = (e2.s.moveSpeed + (T(ab, '3.move_speed') ?? 0)) * MOVE_SCALE * dt;
const nx = e2.x, ny = e2.y;
@@ -638,8 +640,11 @@ function kitGun(ab, e, b) {
});
if (s2) list.push({
cd: s2.cd, icd: s2.icd, cond: (b2) => b2.enemies.some((o) => o.alive && isSpring(o)),
fire(b2, e2) { // 【末日布道】 springs dash to 铳, invulnerable, trampling operators
// PRTS “碎铳之簧” 追逐模式 "不进行普通攻击": `disarm` for the dash (until 0.1.3 it kept shooting while it ran)
fire(b2, e2) { // 【末日布道】 springs chase 铳 for the whole 5 s gain, invulnerable, trampling operators (kitSpring)
// PRTS “碎铳之簧” 追逐模式 "不进行普通攻击": `disarm` for the dash (until 0.1.3 it kept shooting while it ran).
// Each gun casts on its own data timer, and a cast takes every spring — on a pair field the later of the two
// calls retargets them all to its gun (handbook 「持续召唤场上所有“碎铳之簧”向自身移动」; no stagger, no lock while
// a chase runs: PR #347's pair coordination is not taken, the data has neither)
for (const sp of b2.enemies) {
if (!sp.alive || !isSpring(sp) || !sp.mem.ab) continue;
sp.mem.ab.dash = { until: b2.time + (s2.bb.dog_duration ?? 0), mul: 1 + (s2.bb.move_speed ?? 0), gun: e2, hit: new Set() };
@@ -706,12 +711,17 @@ function kitSpring(ab, e) {
tick(b, e2, a, dt) {
if (!P.up && P.downAt != null && b.time - P.downAt >= regen) raise(b);
const d = e2.mem.ab.dash;
if (!d) return;
if (!d || unbalancedNow(b, e2)) return; // (失衡: no scripted move meanwhile)
// 追逐模式 lasts the whole gain (PRTS 末日布道 "令全场的“碎铳之簧”获得5秒增益…增益期间切换为追逐模式"; “碎铳之簧”
// 追逐模式 "持续追踪令其进入该形态的场上的假想敌:铳移动" — no arrival clause, unlike the 铳's own 冲锋模式): reaching
// the gun does not end it, the spring keeps following the gun until the 5 s are over (PR #347 by @CXUtk; until
// 0.2.1 it ended on arrival or within 1 tile). The casting gun dead: the nearest other gun; none left on the
// field: it holds still, the gain running on.
const g = d.gun && d.gun.alive ? d.gun : gun(b);
const arrived = !g || stepToward(e2, g.x, g.y, e2.s.moveSpeed * d.mul * MOVE_SCALE * dt) || Math.hypot(g.x - e2.x, g.y - e2.y) < 1;
if (g) stepToward(e2, g.x, g.y, e2.s.moveSpeed * d.mul * MOVE_SCALE * dt);
// 追逐模式 "对进入自身0.35半径范围内的我方单位(包括飞行单位)造成一次攻击力100%的物理普通伤害" (until 0.1.3: radius 0.5)
for (const u of areaAllies(b, e2, e2.x, e2.y, CHARGE_RADIUS)) if (!d.hit.has(u)) { d.hit.add(u); hurt(b, e2, u, e2.s.atk, 'phys'); }
if (arrived || b.time >= d.until) { e2.mem.ab.dash = null; b.removeBuff(e2, 'boss:dash'); if (e2.route) e2.route.pts = null; }
if (b.time >= d.until) { e2.mem.ab.dash = null; b.removeBuff(e2, 'boss:dash'); if (e2.route) e2.route.pts = null; }
},
},
// while shielded, every (spCost+1)-th attack (enemy SP +1 per attack, as 粉碎攻坚手's official text) adds the shield's
@@ -730,9 +740,9 @@ function kitSpring(ab, e) {
for (let k = 0; k < times && t; k++) {
hit.add(t);
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'springBullet' });
elem(b, e2, t, 'erosion', e2.s.atk * ratio * Math.pow(SPRING_BOUNCE_FALLOFF, k));
elem(b, e2, t, 'erosion', e2.s.atk * ratio * powi(SPRING_BOUNCE_FALLOFF, k));
const prev = t;
t = areaAllies(b, e2, prev.x, prev.y, SPRING_BOUNCE_RANGE).filter((u) => !hit.has(u)).sort((p, q) => Math.hypot(p.x - prev.x, p.y - prev.y) - Math.hypot(q.x - prev.x, q.y - prev.y) || aggroCmp(p, q))[0];
t = areaAllies(b, e2, prev.x, prev.y, SPRING_BOUNCE_RANGE).filter((u) => !hit.has(u)).sort((p, q) => hypot(p.x - prev.x, p.y - prev.y) - hypot(q.x - prev.x, q.y - prev.y) || aggroCmp(p, q))[0];
}
} else { // 十连击
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t0.id, kind: 'springCombo' });
@@ -810,7 +820,7 @@ function pipeCore(ab, e, b, tpl, { form, prefix }) {
tick(b2, e2, a, dt) {
P.acc += dt;
if (P.acc >= (e2.hpRatio < thr ? lo : hi)) { P.acc = 0; summon(b2, e2); }
if (!canCast(e2, false)) return;
if (!canCast(e2, false, b2)) return;
P.atk += dt;
if (P.atk < e2.s.interval) return;
const t = b2.rng.pick(echoesOf(b2, form)); // 优先攻击…形态余音
@@ -1042,7 +1052,7 @@ function kitLion(ab, e, b) {
b2.addBuff(e2, { key: 'boss:advance', persist: true, visible: true, mods: { defPct: T(ab, 'advance.def') ?? 0, resFlat: T(ab, 'advance.magic_resistance') ?? 0 } });
b2.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: 'lionAdvance' });
}
if (!canCast(e2, false)) return;
if (!canCast(e2, false, b2)) return;
P.sp += dt * ENEMY_SP_PER_SEC;
if (P.sp >= cost && decree(b2, e2)) P.sp = 0;
// 【莫非王土】: ANIMATE_DELAY s after equipment first lies on the field, then once per 王权号令 cycle
@@ -1090,7 +1100,7 @@ function kitDeer(ab, e) {
b.addBuff(e2, { key: 'boss:madness', persist: true, visible: true, mods: { physTakenMul: 1 - dr, artsTakenMul: 1 - dr } }); // 物理和法术伤害降低
b.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: 'deerMadness' });
}
if (!canCast(e2, false)) return;
if (!canCast(e2, false, b)) return;
P.acc += dt;
if (P.acc < e2.s.interval) return;
const cands = fairOrder(b, e2, allTargets(b, e2), P);
+5 -4
View File
@@ -34,9 +34,10 @@
// keeps those tiles NONE (grid.js DEPLOY_REFUSED_TILES), so automatic placements never use them [ASSUMED].
import { COLS, ROWS } from '../constants.js';
import { performAttack } from '../ai.js';
import { performAttack, attackCountdown } from '../ai.js';
import { sortEnemyTargets } from '../targeting.js';
import { DIR_VEC, normDir, oppositeDir } from '../dir.js';
import { hypot } from '../detmath.js';
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d));
/** ASPD blackboard values: a fraction (|v| < 1, e.g. −0.6 / −0.05) is ×100 ASPD, otherwise flat (+20). */
@@ -192,7 +193,7 @@ function deviceTile(battle, r0, c0) {
const exact = r === r0 && c === c0;
if (!exact && battle.grid.tile(r, c).build !== 'NONE') continue;
const walk = !exact && battle.grid.groundPassable(r, c, true) ? 1 : 0;
const s = [exact ? 0 : 1, walk, Math.hypot(r - r0, c - c0), r * COLS + c];
const s = [exact ? 0 : 1, walk, hypot(r - r0, c - c0), r * COLS + c];
let less = !bs;
if (!less) for (let i = 0; i < s.length; i++) { if (s[i] < bs[i] - 1e-9) { less = true; break; } if (s[i] > bs[i] + 1e-9) break; }
if (less) { best = [r, c]; bs = s; }
@@ -240,7 +241,7 @@ const TURRET_PROFILE = Object.freeze({ attack: 'ranged', dmgType: 'arts', projec
function tickTurret(battle, u, dt) {
const T = u.mem.turret;
if (!u.alive || !T) return;
T.cd -= dt;
T.cd = attackCountdown(T.cd, dt); // (the engine's attack countdown: a whole number of ticks takes exactly that many)
if (T.cd > 0 || u.s.flags.stun) return;
const L = u.ownerId != null ? topLayers(battle, u.ownerId) : 0;
const bonus = Math.min(L * T.aspdPer, T.aspdMax);
@@ -455,7 +456,7 @@ function tickAirflowEnemy(battle, st, e) {
const r = Math.round(e.y), c = Math.round(e.x);
const f = r >= 0 && r < ROWS && c >= 0 && c < COLS ? st.flow.get(r * COLS + c) : null;
const vx = e.x - px, vy = e.y - py;
const v = Math.hypot(vx, vy);
const v = hypot(vx, vy);
if (f && v > 1e-6) {
const rel = airflowRelation((vx * f.fx + vy * f.fy) / v);
if (rel !== 'vertical') mul = 1 + num(f.bb[`blower_s_enemy[${rel}].move_speed`], 0);
+4 -3
View File
@@ -116,9 +116,10 @@ import { REFLECTION_KITS } from './enemies/reflection.js';
import { FLY_KITS } from './enemies/fly.js';
import { SPECIAL_KITS } from './enemies/special.js';
import { LEADER_KITS } from './enemies/leaders.js';
import { hypot } from '../detmath.js';
export {
EROSION, EROSION_BURST, HUSK_REBIRTH, nthOf, abOf, attach, T, silenced, canCast, elem, hurt, alliesInTiles,
EROSION, EROSION_BURST, HUSK_REBIRTH, nthOf, abOf, attach, T, silenced, canCast, unbalancedNow, elem, hurt, alliesInTiles,
targetsNear, allTargets, areaAllies, areaAlliesInTiles, fieldAllies, auraAllies, targetAndArea, byPriority, zone,
remainingRoute, stayRoute, spawnChildren, stepToward, setHits, hitCount, isHitCount, setForm, lpLoss, expose,
expectedFinal, absorbArts,
@@ -259,7 +260,7 @@ function onTick(b, dt) {
}
if (a.fire && a.cd != null) {
a.left -= dt;
if (a.left <= 1e-9 && canCast(e, a.sil) && (!a.cond || safe(b, e, () => a.cond(b, e, a)))) {
if (a.left <= 1e-9 && canCast(e, a.sil, b) && (!a.cond || safe(b, e, () => a.cond(b, e, a)))) {
a.left = Math.max(TICK, a.cd);
a.casts = (a.casts ?? 0) + 1;
e.skillAnimUntil = b.time + 0.5;
@@ -312,7 +313,7 @@ function chaliceShare(b, c) {
for (const o of b.enemies) {
if (!o.alive || o === t || o.defId !== 'enemy_1430_lrrook' || !o.mem.ab) continue;
const a = o.mem.ab.list[0];
if (!a || !(a.share > 0) || Math.hypot(o.x - t.x, o.y - t.y) > a.r) continue;
if (!a || !(a.share > 0) || hypot(o.x - t.x, o.y - t.y) > a.r) continue;
const part = c.dmg.amount * a.share;
c.dmg.amount -= part;
// a 无来源 burst's share stays 无来源, credited like the burst (damage.js)
+12 -5
View File
@@ -7,7 +7,9 @@ import { MOVE_SCALE, COLS, STEALTH_RESTORE } from '../../constants.js';
import {
HUSK_REBIRTH, BOOM_RADIUS, POLLUTION_INTERVAL, STEALTH_RESTORE_BY_KEY, DUCK_STEALTH_RESTORE, nthOf, stOf, safe, num, T, elem, hurt,
areaAllies, areaAlliesInTiles, zone, spawnChildren, stepToward, setHits, hitCount, setForm, absorbArts, auraBuff,
unbalancedNow,
} from './helpers.js';
import { hypot } from '../../detmath.js';
// ---------------------------------------------------------------------------------------------------------------
// archetypes
@@ -105,7 +107,7 @@ function pathKeysAhead(e) {
if (!R || !Array.isArray(R.pts)) return keys;
let x = e.x, y = e.y;
for (let i = R.ptIdx ?? 0; i < R.pts.length; i++) {
const p = R.pts[i], n = Math.max(1, Math.ceil(Math.hypot(p.x - x, p.y - y) * 4));
const p = R.pts[i], n = Math.max(1, Math.ceil(hypot(p.x - x, p.y - y) * 4));
for (let k = 1; k <= n; k++) keys.add(Math.round(y + ((p.y - y) * k) / n) * COLS + Math.round(x + ((p.x - x) * k) / n));
x = p.x; y = p.y;
}
@@ -180,15 +182,17 @@ const deathSpawn = (key, cnt, extra = {}) => ({
/**
* Death explosion on the allies within r it can select (areaAllies — PRTS 高能源石虫 / 冰爆源石虫 / 卷心籽 死亡爆炸 "…无视迷彩,
* 不可对空", no 无视无法选择: a 隐匿 ally is spared; the dead enemy blocks nobody).
* 不可对空", no 无视无法选择: a 隐匿 ally is spared; the dead enemy blocks nobody). `noAir` (default — all three say so):
* a flying ally (the 炎佑 dragon) is skipped; until 0.2.1 it took the blast (community report: 炎祐吃到了不该吃到的地面伤害).
*/
const deathBoom = ({ scale, type = 'phys', r = BOOM_RADIUS, status = null, sil = true, cond = null }) => ({
const deathBoom = ({ scale, type = 'phys', r = BOOM_RADIUS, status = null, sil = true, cond = null, noAir = true }) => ({
sil,
death(c, b, e, a) {
if (c.reason !== 'killed' || (cond && !cond(e, a))) return;
const atk = e.s.atk;
b.fx('explode', { x: e.x, y: e.y, r, kind: 'deathBoom', id: e.id });
for (const u of areaAllies(b, e, e.x, e.y, r)) {
if (noAir && u.isFlying) continue;
if (scale > 0) hurt(b, e, u, atk * scale, type);
if (status && u.alive) b.applyStatus(u, status.key, { duration: status.dur, source: e, value: status.value });
}
@@ -353,6 +357,7 @@ function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = nu
a.state = 'reborn';
a.t0 = b.time;
a.noAtk = e.profile.noAttack;
e.unbalanceUntil = -Infinity; // a 重生 is a forced state change: it ends a 失衡 (PRTS 失衡位移机制 「例如复活」)
rebirthCleanse(b, e);
e.hp = Math.min(1, e.s.maxHp);
if (onKo) safe(b, e, () => onKo(b, e, a));
@@ -405,7 +410,7 @@ function unbalanced(onMove) {
a.px = e.x; a.py = e.y; a.hid = e.hidden;
if (px == null || hid || e.hidden) return;
const own = e.s.moveSpeed * MOVE_SCALE * dt * 1.5 + 1e-3;
const extra = Math.hypot(e.x - px, e.y - py) - own;
const extra = hypot(e.x - px, e.y - py) - own;
a.lx = px; a.ly = py; // where the move started (direction for onMove)
if (extra > 0.05) onMove(b, e, a, extra);
},
@@ -474,6 +479,7 @@ function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, ke
if (a.state === 'husk' || !(hits > 0) || !(delay > 0)) return false; // the husk's knock-out is the real death
a.state = 'husk';
a.rebornUntil = b.time + HUSK_REBIRTH;
e.unbalanceUntil = -Infinity; // a 重生 is a forced state change: it ends a 失衡 (PRTS 失衡位移机制 「例如复活」)
a.noAtk = e.profile.noAttack;
a.max = a.max ?? e.base.maxHp;
rebirthCleanse(b, e);
@@ -598,6 +604,7 @@ const skill = (s, fire, { cond = null, sil = false, cd = null, icd = null, id =
/** Blink past the blocker along the path (弑君者 / 卢西恩). Returns the start position. */
export function blinkForward(b, e, dist) {
if (unbalancedNow(b, e)) return null; // 失衡: 「无法…使用技能」 — no blink meanwhile
const from = { x: e.x, y: e.y };
const R = e.route;
let left = dist;
@@ -605,7 +612,7 @@ export function blinkForward(b, e, dist) {
let i = R.ptIdx;
while (left > 1e-9 && i < R.pts.length) {
const p = R.pts[i];
const d = Math.hypot(p.x - e.x, p.y - e.y);
const d = hypot(p.x - e.x, p.y - e.y);
if (d <= left) { e.x = p.x; e.y = p.y; left -= d; i++; } else { stepToward(e, p.x, p.y, left); left = 0; }
}
} else if (R && R.legs && R.legs[R.legIdx] && R.legs[R.legIdx].r != null) {
+7 -1
View File
@@ -128,7 +128,13 @@ export const DOT_KITS = Object.freeze({
tick(b, e2) {
const r = (e2.def.raw.stats && e2.def.raw.stats.rawRangeRadius) || 1.6;
b.fx('explode', { x: e2.x, y: e2.y, r, kind: 'boil' });
for (const u of areaAllies(b, e2, e2.x, e2.y, r)) { hurt(b, e2, u, e2.s.atk * (T(ab, 'aoe.atk_scale') ?? 1), 'arts'); elem(b, e2, u, 'burn', e2.s.atk * (T(ab, 'aoe.ep_damage_ratio') ?? 0)); }
// PRTS 鼎沸 "攻击范围内的所有我方单位每秒受到…法术持续伤害与…灼燃损伤(不可对空)": never a flying ally (the 炎佑 dragon;
// until 0.2.1 it burned)
for (const u of areaAllies(b, e2, e2.x, e2.y, r)) {
if (u.isFlying) continue;
hurt(b, e2, u, e2.s.atk * (T(ab, 'aoe.atk_scale') ?? 1), 'arts');
elem(b, e2, u, 'burn', e2.s.atk * (T(ab, 'aoe.ep_damage_ratio') ?? 0));
}
},
}],
});
+24 -14
View File
@@ -1,12 +1,13 @@
// server/sim/content/enemies/fly.js — FLY 飞行 kits (auras, 暴鸰, the “萨科塔”, 假想敌:黑云, shells, seeds) and their part of KITS
// (split from content/enemies.js).
import { TICK, MOVE_SCALE, PROJECTILE_SPEEDS } from '../../constants.js';
import { TICK, MOVE_SCALE, PROJECTILE_SPEEDS, ALLY_COLLIDER_RADIUS } from '../../constants.js';
import {
num, T, hurt, targetsNear, allTargets, areaAllies, areaAlliesInTiles, fieldAllies, byPriority, spawnChildren,
stepToward, setForm, expose,
stepToward, setForm, expose, unbalancedNow,
} from './helpers.js';
import { enemyAura, allyAura, selfFear, skill, kitSelfFear } from './archetypes.js';
import { hypot } from '../../detmath.js';
// ---------------------------------------------------------------------------------------------------------------
// constants (numbers that exist nowhere in the data)
@@ -26,9 +27,14 @@ export const BOMBD_RELEASE = 8 / 30;
* 提升至200%"; the cast's end clip is already the bomb-less Idle_2 (`_endAnimKey`). */
export const BOMBD_POST_DELAY = 0.667;
/** 帝国炮火先兆者 shell (PRTS "普通攻击向目标所在位置发射一枚于3秒后命中的弹道,弹道对半径1.2范围内的所有我方单位造成攻击力
* 100%的无来源物理伤害 … ※弹道始终使用缓存攻击力"): flight time and blast radius. */
const SHELL_FLIGHT = 3, SHELL_RADIUS = 1.2;
/** 帝国炮火先兆者 shell (PRTS 天赋 of both 先兆者 "普通攻击向目标所在位置发射一枚于3秒后命中的弹道,弹道对半径1.2范围内的所有我方单位造成
* 攻击力100%的无来源物理伤害(此弹道不会强制击中主目标,碰撞无视迷彩)… ※弹道始终使用缓存攻击力"): flight time and the written
* blast radius. The landing is a 碰撞 — PRTS 作战机制 §碰撞体积: collider tests are the common way, an ally's collider a
* circle of ALLY_COLLIDER_RADIUS — so an ally is hit when its collider touches the 1.2 circle: centre distance ≤ 1.45
* (SHELL_REACH), the impact tile and the 8 around it (a diagonal tile centre is √2 ≈ 1.414 away; two tiles, 2, stay
* out) — the 3×3 a player saw in the official game (GitHub #364; until 0.2.2 the bare 1.2: the cross only).
* [ASSUMED] for this shell (its text names the 碰撞); other content zones keep their point radius (constants.js). */
const SHELL_FLIGHT = 3, SHELL_RADIUS = 1.2, SHELL_REACH = SHELL_RADIUS + ALLY_COLLIDER_RADIUS;
/** 假想敌:黑云 抓取: 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. */
@@ -62,7 +68,7 @@ function kitBlackCloud(ab, e) {
const prey = (b, e2) => b.enemiesInRadius(e2.x, e2.y, grabR).filter((o) => isPrey(b, e2, o));
return [
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);
const list = prey(b, e2).sort((p, q) => hypot(p.x - e2.x, p.y - e2.y) - 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' });
@@ -116,12 +122,13 @@ function kitSteal(ab) {
}
/** “萨科塔昂首” 【祈祷邀约】 (PRTS "令全场我方单位(不可对空,无视迷彩)获得15s【受邀祈祷】攻击速度-30"): a whole-field skill
* selection — every ally but an unblocking 隐匿 / untargetable / sleeping one (fieldAllies; no 无视无法选择). */
* selection — every ally but an unblocking 隐匿 / untargetable / sleeping one (fieldAllies; no 无视无法选择) and, 不可对空, a
* flying one (the 炎佑 dragon; until 0.2.1 it was slowed too). */
function kitRoar(ab) {
const s = ab.sk.Roar;
return [selfFear(ab), skill(s, (b, e) => {
b.fx('telegraph', { x: e.x, y: e.y, r: 99, kind: 'roar', id: e.id });
for (const u of fieldAllies(b, e)) b.addBuff(u, { key: 'ab:roar', duration: s.bb.duration ?? 0, refresh: 'extend', mods: { aspd: s.bb.attack_speed ?? 0 }, visible: true });
for (const u of fieldAllies(b, e)) if (!u.isFlying) b.addBuff(u, { key: 'ab:roar', duration: s.bb.duration ?? 0, refresh: 'extend', mods: { aspd: s.bb.attack_speed ?? 0 }, visible: true });
}, { sil: true })];
}
@@ -205,7 +212,8 @@ function kitBombd(ab) {
/**
* 帝国炮火先兆者 / 中枢先兆者 (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
* later and deals 100 % of the ATK at launch as physical damage without a source to every ally whose collider touches the
* SHELL_RADIUS circle (SHELL_REACH)
* (it may miss the target that moved away; "碰撞无视迷彩"). The landing is the shell's area selection (areaAllies of the
* enemy that fired it: no unblocking 隐匿 ally; the damage stays 无来源). The attack itself is the engine's (cooldown,
* pause, 'atk' event of kind 'mortar'); the damage is the shell's (ai.js `profile.deferHit`).
@@ -217,10 +225,11 @@ function kitShell() {
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 });
// (the telegraph and the blast are drawn at the reach: every ally whose centre lies in the circle is hit)
b.fx('bombardShell', { x, y, id: e.id, r: SHELL_REACH, t: SHELL_FLIGHT });
b.after(SHELL_FLIGHT, () => {
b.fx('bombard', { x, y, r: SHELL_RADIUS, kind: 'emppnt' });
for (const u of areaAllies(b, e, x, y, SHELL_RADIUS)) hurt(b, null, u, atk, 'phys', { isSkill: false, tags: ['shell'] });
b.fx('bombard', { x, y, r: SHELL_REACH, kind: 'emppnt' });
for (const u of areaAllies(b, e, x, y, SHELL_REACH)) hurt(b, null, u, atk, 'phys', { isSkill: false, tags: ['shell'] });
});
}
},
@@ -249,13 +258,14 @@ export const FLY_KITS = Object.freeze({
spawn(b, e) { e.profile.noAttack = true; },
tick(b, e, a, dt) {
if (a.t && !a.t.alive) { a.t = null; b.removeBuff(e, 'ab:dive'); }
if (unbalancedNow(b, e)) return; // 失衡: no dive, no blast meanwhile
if (!a.t) {
const l = targetsNear(b, e, e.base.rangeRadius || 1).sort((p, q) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y));
const l = targetsNear(b, e, e.base.rangeRadius || 1).sort((p, q) => hypot(p.x - e.x, p.y - e.y) - hypot(q.x - e.x, q.y - e.y));
if (!l.length) return;
a.t = l[0];
b.addBuff(e, { key: 'ab:dive', flags: { noMove: true } });
}
if (!stepToward(e, a.t.x, a.t.y, Math.max(e.s.moveSpeed, 0.5) * MOVE_SCALE * dt) && Math.hypot(a.t.x - e.x, a.t.y - e.y) > 0.3) return;
if (!stepToward(e, a.t.x, a.t.y, Math.max(e.s.moveSpeed, 0.5) * MOVE_SCALE * dt) && hypot(a.t.x - e.x, a.t.y - e.y) > 0.3) return;
hurt(b, e, a.t, e.s.atk, 'phys');
b.fx('explode', { x: e.x, y: e.y, r: 0.5, kind: 'seed' });
b.kill(e, null);
+11 -2
View File
@@ -6,6 +6,7 @@
import { ELEMENT } from '../../constants.js';
import { canTargetAlly, sortAllyTargets, areaSelectable, auraSelectable } from '../../targeting.js';
import { mitigate } from '../../damage.js';
import { hypot } from '../../detmath.js';
// ---------------------------------------------------------------------------------------------------------------
// constants (numbers that exist nowhere in the data)
@@ -131,7 +132,15 @@ export function T(ab, ...keys) { for (const k of keys) { const v = ab.t[k]; if (
export function silenced(e) { return !!e.s.flags.silence; }
export function canCast(e, sil) { return e.alive && !e.hidden && !e.s.flags.stun && !(sil && e.s.flags.silence); }
/**
* Is enemy `e` in its 失衡 (UNBALANCE) state now (battle/displacement.js _unbalance; the 1e-9 keeps a float-summed
* end — 35/30 s — on its frame)? PRTS 异常效果图鉴/失衡免疫: 「失衡期间无法自主移动、发动攻击、使用技能」 — content skills
* (canCast), blinks and scripted moves wait for its end.
*/
export function unbalancedNow(b, e) { return !!(b && e && b.time < e.unbalanceUntil - 1e-9); }
/** Can enemy `e` use a skill now: alive, on the field, not stunned, not silenced (`sil`), not in its 失衡 state (`b` given). */
export function canCast(e, sil, b = null) { return e.alive && !e.hidden && !e.s.flags.stun && !(sil && e.s.flags.silence) && !unbalancedNow(b, e); }
// ---------------------------------------------------------------------------------------------------------------
// helpers (exported for bosses.js)
@@ -295,7 +304,7 @@ export function spawnChildren(b, parent, key, n, opts = {}) {
/** Move a unit straight toward (tx, ty) by `dist` tiles. Returns true on arrival. */
export function stepToward(u, tx, ty, dist) {
const dx = tx - u.x, dy = ty - u.y;
const d = Math.hypot(dx, dy);
const d = hypot(dx, dy);
if (d <= dist + 1e-9) { u.x = tx; u.y = ty; return true; }
u.x += (dx / d) * dist;
u.y += (dy / d) * dist;
+22 -6
View File
@@ -2,13 +2,16 @@
// content/enemies.js).
import { canTargetAlly, enemyStealthed } from '../../targeting.js';
import { ALLY_COLLIDER_RADIUS } from '../../constants.js';
import { T, elem, hurt, targetsNear, byPriority, auraBuff } from './helpers.js';
import { stealth, onHitStatus, skill, kitStealth } from './archetypes.js';
import { hypot } from '../../detmath.js';
// ---------------------------------------------------------------------------------------------------------------
// constants (numbers that exist nowhere in the data)
/** "数个目标" of 假想敌:骨刺 while stealthed [ASSUMED]. */
/** 假想敌:骨刺's targets at once while its 隐匿 holds: 3 — the level's own description 「隐匿;隐匿状态下同时攻击3个目标。」
* (enemy_database; the handbook line says 「数个目标」) and PRTS 天赋 「隐匿期间可同时攻击3个目标」 (GitHub #365). */
const ACBUNN_TARGETS = 3;
/** 重弩突袭者 直击 reach along a row/column [ASSUMED]; the skill's length and its charge before the bolt (PRTS 直击 "蓄力1.4s后
@@ -20,8 +23,8 @@ const CROSS_REACH = 6, CROSS_SKILL = 2.5, CROSS_CHARGE = 1.4;
function kitCrossbow(ab) {
const s = ab.sk.CrossAttack;
const aligned = (b, e) => b.allies().filter((u) => canTargetAlly(e, u, true) && (Math.abs(u.y - e.y) < 0.5 || Math.abs(u.x - e.x) < 0.5) && Math.hypot(u.x - e.x, u.y - e.y) <= CROSS_REACH);
const nearest = (b, e, l) => l.sort((p, q) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y))[0];
const aligned = (b, e) => b.allies().filter((u) => canTargetAlly(e, u, true) && (Math.abs(u.y - e.y) < 0.5 || Math.abs(u.x - e.x) < 0.5) && hypot(u.x - e.x, u.y - e.y) <= CROSS_REACH);
const nearest = (b, e, l) => l.sort((p, q) => hypot(p.x - e.x, p.y - e.y) - hypot(q.x - e.x, q.y - e.y))[0];
// PRTS 重弩突袭者: 天赋 "隐匿(被阻挡,主动攻击期间均可解除)"; 直击 "蓄力1.4s后向目标方向发射1支弩箭,对击中的首个目标造成攻击力100%
// 的法术伤害与5s晕眩 ※技能持续2.5s": revealed for the whole skill, the bolt leaves CROSS_CHARGE s in — at the nearest unit still
// in line in the aimed direction; it stands meanwhile [ASSUMED], and a stun / silence or its death before the release
@@ -108,16 +111,29 @@ function kitShadowBlade(ab) {
tick(b, e) {
const partner = b.enemiesInRadius(e.x, e.y, 3).find((o) => /enemy_1174_duholy/.test(o.defId));
const r = partner && partner.mem.ab ? (T(partner.mem.ab, 'traitAbility.range_radius') ?? 1.1) : 1.1;
if (partner && Math.hypot(partner.x - e.x, partner.y - e.y) <= r + 1e-9 && e.base.bat > 0) auraBuff(b, e, 'ab:shadowSync', 0.5, { batPct: bat / e.base.bat });
if (partner && hypot(partner.x - e.x, partner.y - e.y) <= r + 1e-9 && e.base.bat > 0) auraBuff(b, e, 'ab:shadowSync', 0.5, { batPct: bat / e.base.bat });
},
}];
}
/**
* 假想敌:骨刺 — PRTS 天赋 (级别0): 「自身普通攻击索敌不受阻挡影响」 — profile `blockFree` (as 自制投石机): blocked, and so out of its
* 隐匿, it still picks by priority among the allies in reach, not its blocker first, and a 隐匿 / 迷彩 blocker is no target
* (targeting.js canTargetAlly); 「不会攻击飞行单位」 — profile `canTarget`; 「隐匿(解除阻挡0秒后恢复)」 — stealth(); 「隐匿期间
* 可同时攻击3个目标」: while its 隐匿 holds, the first ACBUNN_TARGETS by priority among the allies its normal attack could hit
* (ai.js attackTargets: the same reach — rangeRadius + the ally collider — and the same rules); one otherwise. Until
* 0.2.2 (PR #365 by @Sukvii, the PRTS lines checked for this port): blocker first, flyers hit, the 3 picked within the bare
* rangeRadius.
*/
function kitBoneSpike() {
return [stealth(), {
spawn(b, e) {
e.profile.blockFree = true;
e.profile.canTarget = (u) => !u.isFlying;
},
before(c, b, e) {
if (!enemyStealthed(e)) return; // 隐匿状态下同时攻击数个目标 (back 0 s after a block)
const l = byPriority(e, targetsNear(b, e, e.base.rangeRadius));
if (!enemyStealthed(e)) return;
const l = byPriority(e, targetsNear(b, e, e.base.rangeRadius + ALLY_COLLIDER_RADIUS).filter(e.profile.canTarget));
if (l.length) c.targets = l.slice(0, ACBUNN_TARGETS);
},
}];
+25 -15
View File
@@ -6,12 +6,13 @@ import { aggroCmp, areaSelectable } from '../../targeting.js';
import { periodicDamage } from '../../damage.js';
import {
nthOf, T, hurt, alliesInTiles, targetsNear, allTargets, areaAllies, areaAlliesInTiles, auraAllies, targetAndArea,
byPriority, setForm, auraBuff, watchDeaths,
byPriority, setForm, auraBuff, watchDeaths, unbalancedNow,
} from './helpers.js';
import {
unblockable, maxTargets, onHitStatus, splashAttack, pathKeysAhead, noAirTargets, resist, nthAttackStatus, lowHpBuff,
freeAllPrisoners, reborn, frontGuard, faceCrowd, unbalanced, artsBarrier, skill, blinkForward,
} from './archetypes.js';
import { hypot, powi } from '../../detmath.js';
// ---------------------------------------------------------------------------------------------------------------
// constants (numbers that exist nowhere in the data)
@@ -59,8 +60,6 @@ const XI_CROSS_REACH = 2;
* Until 0.1.3: 2.5 / 2.5 / −30, the aura only during the 重生 [ASSUMED]. */
const WOLF_RANGE = 1.25, WOLF_AWE_RADIUS = 1.5, WOLF_AWE_ASPD = -50;
/** 失衡 movement speed that turns 弧光锋卫's per-interval bleed into HP per tile moved [ASSUMED]. */
const UNBALANCE_SPEED = 5;
/** 乌顶巨角卢鲁 【角力对决】: the operator cannot be pushed (fixed tiles) ⇒ "更多伤害" multiplier [ASSUMED]. */
const ELK_FAIL_SCALE = 2;
@@ -312,7 +311,7 @@ function kitLeaderMisc(key, ab, e) {
const nx = areaAllies(b, e2, prev.x, prev.y, jr).find((u) => !hit.has(u));
if (!nx) break;
hit.add(nx);
hurt(b, e2, nx, e2.s.atk * Math.pow(fall, k), 'arts');
hurt(b, e2, nx, e2.s.atk * powi(fall, k), 'arts');
if (nx.alive && cold > 0) b.applyStatus(nx, 'cold', { duration: cold, source: e2 });
prev = nx;
}
@@ -323,7 +322,7 @@ function kitLeaderMisc(key, ab, e) {
skill(cb, (b, e2) => {
const bb = cb.bb, jump = bb.projectile_range ?? jr, max = bb['chain.max_target'] ?? 3;
const near = (x, y, r, seen) => b.enemiesInRadius(x, y, r).filter((o) => o !== e2 && !seen.has(o))
.sort((p, q) => Math.hypot(p.x - x, p.y - y) - Math.hypot(q.x - x, q.y - y) || p.spawnSeq - q.spawnSeq)[0];
.sort((p, q) => hypot(p.x - x, p.y - y) - hypot(q.x - x, q.y - y) || p.spawnSeq - q.spawnSeq)[0];
const seen = new Set();
let prev = e2, cur = near(e2.x, e2.y, e2.base.rangeRadius || 3.5, seen);
while (cur && seen.size < max) {
@@ -357,7 +356,7 @@ function kitLeaderMisc(key, ab, e) {
function segDist(px, py, ax, ay, bx, by) {
const dx = bx - ax, dy = by - ay, L = dx * dx + dy * dy;
const t = L > 0 ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / L)) : 0;
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
return hypot(px - (ax + t * dx), py - (ay + t * dy));
}
/** A second normal hit on the same target ("二连击"): same damage type, no further on-hit procs. */
@@ -454,7 +453,7 @@ function kitWarden(ab) {
function kitXi(ab) {
const cross = ab.sk.CrossAttack, sb = ab.sk.ShieldBurst, sb2 = ab.sk.ShieldBurstReborn;
const P = { form2: false };
const byDist = (b, e) => allTargets(b, e).sort((p, q) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y) || aggroCmp(p, q));
const byDist = (b, e) => allTargets(b, e).sort((p, q) => hypot(p.x - e.x, p.y - e.y) - hypot(q.x - e.x, q.y - e.y) || aggroCmp(p, q));
const crossAt = (b, e, t) => {
const r0 = t.tileR, c0 = t.tileC;
b.fx('telegraph', { x: c0, y: r0, r: XI_CROSS_REACH, kind: 'xiCross', tiles: 'plus', id: e.id });
@@ -721,7 +720,7 @@ function kitElk(ab) {
const ch = P.ch;
if (!ch) return;
if (e.s.flags.stun || !ch.t.alive || e.blockedBy !== ch.t) { stop(b, e); b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'chargeBroken' }); return; }
if (b.time + 1e-9 < ch.until) return;
if (b.time + 1e-9 < ch.until || unbalancedNow(b, e)) return; // a 失衡 that did not move it holds the clash (no skill meanwhile)
stop(b, e);
b.fx('explode', { x: ch.t.x, y: ch.t.y, r: 0.5, kind: 'elkClash' });
hurt(b, e, ch.t, e.s.atk * ((s && s.bb.atk_scale_s) ?? 1) * ELK_FAIL_SCALE, 'phys');
@@ -772,18 +771,29 @@ export const LEADER_KITS = Object.freeze({
enemy_2085_skzjxd: (ab) => [unblockable(), frontGuard(T(ab, 'Weakness.damage_resistance') ?? 0, faceCrowd)],
enemy_2085_skzjxd_2: (ab) => [unblockable(), frontGuard(T(ab, 'Weakness.damage_resistance') ?? 0, faceCrowd)], // (鸭爵 strategy) same
// 失衡 (pushed / pulled by operators)
enemy_1328_cbjedi: (ab) => [unbalanced((b, e, a, d) => { // 弧光锋卫 · takes damage in proportion to the distance moved while unbalanced
// PRTS 修正 "失衡移动时持续受到真实伤害", "处于失衡状态时,每0.066s受到400点无来源真实持续伤害" (伤害分类: 弧光锋卫失衡状态下的自残
// 伤害 is BUFF damage): damage, not a 流失 (player report D1 audit)
const v = T(ab, 'unbalanced_bleed.damage') ?? 0, iv = T(ab, 'unbalanced_bleed.interval') ?? 1;
if (v > 0 && iv > 0) b.dealDamage(null, e, { ...periodicDamage((v * d) / (iv * UNBALANCE_SPEED)), tags: ['dot', 'periodic', 'unbalanced'] });
})],
enemy_1328_cbjedi: (ab) => { // 弧光锋卫 · bleeds while in its 失衡 state
// PRTS 修正 "失衡移动时持续受到真实伤害", 天赋 "处于失衡状态时,每0.066s受到400点无来源真实持续伤害" (data unbalanced_bleed.damage /
// .interval; 伤害分类: 弧光锋卫失衡状态下的自残伤害 is BUFF damage): damage, not a 流失 (player report D1 audit) — every `interval`
// s for as long as the state lasts (battle/displacement.js _unbalance: a push's 位移时间, a pull's force window even
// after the 急停 or with no movement). Until 0.2.2 one hit in proportion to the tiles moved [ASSUMED], from the position jump.
const v = T(ab, 'unbalanced_bleed.damage') ?? 0, iv = T(ab, 'unbalanced_bleed.interval') ?? 0;
return [{
tick(b, e, a, dt) {
if (!(v > 0 && iv > 0) || !unbalancedNow(b, e)) { a.bleed = 0; return; }
a.bleed = (a.bleed ?? 0) + dt;
while (a.bleed >= iv - 1e-9 && e.alive) {
a.bleed -= iv;
b.dealDamage(null, e, { ...periodicDamage(v), tags: ['dot', 'periodic', 'unbalanced'] });
}
},
}];
},
enemy_10112_ymgds: (ab) => [unbalanced((b, e) => { // 冒失的小弟 · stunned after being unbalanced
const st = T(ab, 'StunAfterUnbalance.stun') ?? 0;
if (st > 0) b.applyStatus(e, 'stun', { duration: st, source: null });
})],
enemy_10138_xdsnow: (ab) => [unbalanced((b, e, a) => { // 雪孩子 · pushed / pulled into high ground ⇒ hitWall.value damage
const dx = e.x - a.lx, dy = e.y - a.ly, d = Math.hypot(dx, dy);
const dx = e.x - a.lx, dy = e.y - a.ly, d = hypot(dx, dy);
if (!(d > 0)) return;
const r = Math.round(e.y + (dy / d) * 0.6), c = Math.round(e.x + (dx / d) * 0.6);
if (b.grid.isLow(r, c) && b.grid.groundPassable(r, c)) return; // stopped by nothing: no collision
+6 -4
View File
@@ -12,6 +12,7 @@ import {
unblockable, runWhenHit, blockWeight, taunt, maxTargets, ep, nthAttackPower, lowHpBuff, deathBoom, setFloat, float,
reborn, frontGuard, faceMove, unbalanced, skill, kitEp, kitPrisoner, kitStun3,
} from './archetypes.js';
import { hypot } from '../../detmath.js';
// ---------------------------------------------------------------------------------------------------------------
// constants (numbers that exist nowhere in the data)
@@ -245,7 +246,7 @@ export const SPECIAL_KITS = Object.freeze({
enemy_1183_mlasrt: (ab) => [ep('erosion', T(ab, 'EpDamage.attack@ep_damage_ratio') ?? 0), nthAttackPower(nthOf(ab.sk.PowerAttack), (ab.sk.PowerAttack && ab.sk.PowerAttack.bb.atk_scale) || 1)], // 无胄盟清扫小队 · erosion; every 4th attack ×1.5
enemy_1273_stmgun_2: (ab) => { // 高准度伦蒂尼姆城防自行炮 · locks the highest max-HP unit in range, bombards the highest HP% on its 9 tiles
// the allies in its range (the engine's ranged reach: radius + the ally collider)
const inRange = (b, e) => allTargets(b, e).filter((a) => Math.hypot(a.x - e.x, a.y - e.y) <= (e.base.rangeRadius || 0) + ALLY_COLLIDER_RADIUS + 1e-9);
const inRange = (b, e) => allTargets(b, e).filter((a) => hypot(a.x - e.x, a.y - e.y) <= (e.base.rangeRadius || 0) + ALLY_COLLIDER_RADIUS + 1e-9);
return [skill(ab.sk.Cannon, (b, e) => {
const lock = inRange(b, e).sort((p, q) => q.s.maxHp - p.s.maxHp)[0];
if (!lock) return;
@@ -279,7 +280,7 @@ export const SPECIAL_KITS = Object.freeze({
spawn(b, e) { watchDeaths(b, e); },
otherDeath(c, b, e) {
const u = c.unit;
if (!u || u === e || c.reason !== 'killed' || Math.hypot(u.x - e.x, u.y - e.y) > (T(ab, 'Attack.range_radius') ?? TORTURER_RADIUS) + 1e-9) return;
if (!u || u === e || c.reason !== 'killed' || hypot(u.x - e.x, u.y - e.y) > (T(ab, 'Attack.range_radius') ?? TORTURER_RADIUS) + 1e-9) return;
b.heal(e, e, e.s.maxHp * (T(ab, 'Attack.hp_ratio') ?? 0), { self: true });
b.addBuff(e, { key: 'ab:torture', refresh: 'stack', stacks: 1, maxStacks: T(ab, 'Attack.max_stack_cnt') ?? 1, persist: true, mods: { atkPct: T(ab, 'Attack.atk') ?? 0 } });
},
@@ -378,10 +379,11 @@ export const SPECIAL_KITS = Object.freeze({
fire(b, e, a) { a.until = b.time + (T(ab, 'EndRotate.rotate_duration') ?? 0); b.fx('telegraph', { x: e.x, y: e.y, r: 1, kind: 'spin', id: e.id }); },
iv: T(ab, 'RotateDamage.interval') ?? 1,
// "每秒对半径1.0范围内的所有我方单位造成…(无视迷彩,不可对空),自身受阻止攻击类异常效果影响期间无法造成此伤害": an area
// selection, skipped while 晕眩 / 冻结 / 浮空 (flag stun), 沉睡 or 缴械 hold it (PRTS 异常效果 §阻止攻击; until 0.1.3 it spun on)
// selection, skipped while 晕眩 / 冻结 / 浮空 (flag stun), 沉睡 or 缴械 hold it (PRTS 异常效果 §阻止攻击; until 0.1.3 it spun on);
// 不可对空: never a flying ally (the 炎佑 dragon — until 0.2.1 it was hit)
tick(b, e, a) {
if (!(a.until > b.time) || e.s.flags.stun || e.s.flags.sleep || e.s.flags.disarm) return;
for (const u of areaAllies(b, e, e.x, e.y, T(ab, 'RotateDamage.attack@range_radius') ?? 1)) hurt(b, e, u, e.s.atk * (T(ab, 'RotateDamage.attack@atk_scale') ?? 1), 'phys');
for (const u of areaAllies(b, e, e.x, e.y, T(ab, 'RotateDamage.attack@range_radius') ?? 1)) if (!u.isFlying) hurt(b, e, u, e.s.atk * (T(ab, 'RotateDamage.attack@atk_scale') ?? 1), 'phys');
},
};
// 漩涡形态 "不进行普通攻击" (PRTS 天赋): no blocked attack while it spins (until 0.1.3 its blocker took both)
+4 -1
View File
@@ -400,7 +400,10 @@ function applyReader(battle, it) {
else battle.removeBuff(u, key);
break;
case 'act1autochess_gar_eff_respawnTimeByBond':
if (k > 0) S.passiveBuff(battle, u, key, { redeployMul: Math.max(0.05, 1 + S.num(it.bb.respawn_time, 0) * k) });
// 耀骑士临光's 144: −1.5 % / −3 % per 3 卡西米尔 layers with no minimum in the blackboard (divide_num, respawn_time
// only) — clamped at 0 like every other redeploy multiplier, so a knock-out timer can reach 0 s (GitHub #370;
// until 0.2.1 a 0.05 floor kept it at 5 % of the base time from 192 layers, 96 for the elite)
if (k > 0) S.passiveBuff(battle, u, key, { redeployMul: Math.max(0, 1 + S.num(it.bb.respawn_time, 0) * k) });
else battle.removeBuff(u, key);
break;
case 'act2autochess_gar_eff_attrByBond_add_onstart': {
+4 -1
View File
@@ -28,6 +28,7 @@
import { genericKit, genericTalents } from './generic.js';
import { withUnitLoadouts } from '../simdata.js';
import { isPotential } from '../../../shared/potential.js';
// Content files are loaded with guarded dynamic imports: a module that fails to load (syntax error, throwing
// top-level code, missing file) is logged and replaced by an empty module instead of breaking the server.
@@ -136,7 +137,8 @@ function inputEntry(unit) {
* The loadout `{ skillIndex, moduleId }` a unit's PlayerBattleInput entry gives; an entry without loadout fields means
* the DEFAULT loadout (`{}`), never "whatever the data view maps this chess id to" (another player's choice in a
* multi-player field). `standIn: true` rides along (补位: the stand-in def whatever the skill / module fields say), as
* does `diy` (自选: the pick of a DIY slot's piece). Null when the unit has no input entry.
* do `diy` (自选: the pick of a DIY slot's piece) and `potential` (0.2.2: 1–6, never a stand-in's). Null when the unit
* has no input entry.
*/
function inputLoadout(unit) {
const x = inputEntry(unit);
@@ -144,6 +146,7 @@ function inputLoadout(unit) {
const extra = {
...(x.standIn === true ? { standIn: true } : null),
...(x.diy && typeof x.diy === 'object' ? { diy: x.diy } : null),
...(x.standIn !== true && isPotential(x.potential) ? { potential: x.potential } : null),
};
if (x.skillIndex == null && x.moduleId == null) return extra;
return { skillIndex: x.skillIndex ?? null, moduleId: x.moduleId ?? null, ...extra };
+2 -1
View File
@@ -47,6 +47,7 @@ import {
alliesAround, passiveBuff, fxOn, battleStore, contentInfo, itemsOf, directMods,
} from '../support/index.js';
import { mitigate, hasHp, periodicDamage, isHpLoss } from '../../damage.js';
import { hypot } from '../../detmath.js';
// =====================================================================================================================
// data helpers
@@ -465,7 +466,7 @@ const BY_ITEM = {
const r = num(p.radius, 3), sc = num(p.damage_scale, 1);
S.on('hit', (c) => {
if (c.source !== u || !c.target || c.target.side !== 'enemy' || c.dmg.type === 'element') return;
if (Math.hypot(c.target.x - u.x, c.target.y - u.y) >= r - 1e-6) c.dmg.mul *= sc;
if (hypot(c.target.x - u.x, c.target.y - u.y) >= r - 1e-6) c.dmg.mul *= sc;
});
},
// 炎国短刀: each skill activation +atk (≤ atk_buff_cnt stacks) — one 直接乘算 bonus of atk × stacks
+48 -34
View File
@@ -13,17 +13,19 @@
// the target's items skipped the item after a merge)
// 紧急调度券 a shop operator leaves its slot only when it was actually granted (built-in cleared the slot first)
// 寻呼模块 the special refresh shows `refresh_cnt` DIFFERENT operators (fewer when the pool has no more)
// 教鞭 trap_create_self_choice {choice_event: hunter_band_1}: the bounty is a 战术特训 card (choices.json
// cards.bounty payout `perfect`, e.g. 战术特训·飞行I "若各自行动阶段就达成完美作战,获得1资金") — PRTS
// 卫戍协议:盟约 下半/PRTS盟约记录 §法术 教鞭 "于3个战术特训的悬赏任务中选择一项", §机变阶段 "※以下悬赏任务仅由
// 法术教鞭生成", and 杜宾 加练! "<教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金" (user
// playtest #6 item 4 review; the built-in drew any tier ≤ II kill bounty, and research 04 §7 [ASSUMED]
// the band family `enemyeffect_b_*`).
// “神秘顾客” the same trap_create_self_choice ("选择一项特殊悬赏任务"; no band grants it in act2, research 04): a band
// bounty `enemyeffect_b_*` (research 04 §7 [ASSUMED]: "adds 1 enemy to your next battle, killer gets `coin`
// funds").
// Both: [ASSUMED simplification, engine: no PERSONAL_CHOOSE overlay] 3 cards whose enemy can appear in the
// mode are drawn and one of them is taken at random; the built-in runs when the family is empty.
// 教鞭 trap_create_self_choice {choice_event: hunter_band_1, choiceType PERSONAL_CHOOSE}: a personal choice of
// one of THREE 战术特训 cards (choices.json cards.bounty payout `perfect`, e.g. 战术特训·飞行I "若各自行动阶段就
// 达成完美作战,获得1资金") whose enemy the mode can field — PRTS 卫戍协议:盟约 下半/PRTS盟约记录 §法术 教鞭
// "使用后销毁,于3个战术特训的悬赏任务中选择一项", §机变阶段 "※以下悬赏任务仅由法术教鞭生成", and 杜宾 加练!
// "<教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金". onArt → ctx.offerBountyChoice
// (Match.offerBountyChoice): the three cards go to the owner alone, who confirms one inside the PREP
// (g.choice with the offer's id); the prep's deadline or an AI seat resolves it (docs/META.md §2.5). Until
// 0.2.2 the engine took one of the three at random (user playtest #6 item 4 review; the built-in drew any
// tier ≤ II kill bounty, and research 04 §7 [ASSUMED] the band family `enemyeffect_b_*`).
// “神秘顾客” the same trap_create_self_choice ("选择一项特殊悬赏任务"; no band grants it in act2, research 04, and PRTS
// lists no pool): still [ASSUMED simplification] 3 band bounties `enemyeffect_b_*` (research 04 §7 [ASSUMED]:
// "adds 1 enemy to your next battle, killer gets `coin` funds") whose enemy can appear in the mode, one of them
// taken at random; the built-in runs when the family is empty.
// “神秘顾客” trap_disney_special: when actively destroyed, +count funds and the Art passes to the next alive player
// (seat order, cyclic)
// 天师古鼎 equip_with_another_gain_coin_when_gain_char: a 【炎】 carrier also holding 炎国短刀 (either quality)
@@ -31,6 +33,7 @@
import { itemKeyOf, isCoreBond } from '../support/index.js';
import { metaBonds } from '../support/meta.js';
import { msg, dn } from '../../../../shared/i18n.js';
const int = (v, d = 0) => (Number.isFinite(v) ? Math.trunc(v) : d);
@@ -48,7 +51,7 @@ function pieceIsMember(ctx, piece, bondId) {
return bonds.includes('maniShip') && isCoreBond(bondId) && ctx.bondActive('maniShip') && ctx.bondActive(bondId);
}
/** Offer size of the personal bounty choice (PRTS 法术 教鞭 "于3个战术特训的悬赏任务中选择一项"). */
/** Cards drawn before “神秘顾客” takes one at random. */
const OFFER_SIZE = 3;
/** cards.bounty entries matching `test` whose enemy can appear in this mode. */
function bountyCards(ctx, test) {
@@ -100,38 +103,49 @@ export function registerMeta(registry) {
if (id) ids.push(id);
}
if (ids.length) ctx.offerChess(ids, { source: 'item' });
// nothing to offer (e.g. 缪尔赛思's 调和 below shop level 6): still destroyed — say why (GitHub #401, builtinMeta toastNothing)
else ctx.toast(msg('{who}:没有可获得的同盟约干员', { who: dn(ctx.gd.item(ev.item?.id)?.name || '') }), 'warn');
},
}));
// 教鞭 / “神秘顾客” — trap_create_self_choice {choice_event}: 教鞭 a 战术特训 card, “神秘顾客” a band bounty (enemyeffect_b_*)
for (const [key, family] of [['chess_item_6_03_m', isTraining], ['chess_item_6_01_m', isBandBounty]]) {
wrap(registry, key, (base) => ({
onArt(ctx, ev) {
const cards = bountyCards(ctx, family);
if (!cards.length) { if (typeof base.onArt === 'function') base.onArt.call(base, ctx, ev); return; }
const offer = ctx.rng.shuffle(cards.slice()).slice(0, OFFER_SIZE);
const card = ctx.rng.pick(offer);
if (!card || !ctx.addBounty(card)) { ev.error = 'BAD_TARGET'; ev.detail = 'no bounty available'; }
},
}));
}
// 教鞭 — trap_create_self_choice {choice_event}: the server keeps the offered 战术特训 cards until confirmed.
wrap(registry, 'chess_item_6_03_m', () => ({
onArt(ctx, ev) {
const result = ctx.offerBountyChoice(bountyCards(ctx, isTraining), ev.item.id);
if (!result.ok) { ev.error = result.error; ev.detail = result.detail; }
},
}));
// 画卷 — trap_copy_front_char: copy the operator in range (elite status included) with its equipment. A copied
// normal item that completes a pair with an owned one merges at once and the golden stays in the hand (research 04
// addendum "两个同名道具(无论是否被装备)会自动合并…并自动返回整备区"); the built-in equipped that golden on the copy.
// “神秘顾客” — a random band bounty (enemyeffect_b_*).
wrap(registry, 'chess_item_6_01_m', (base) => ({
onArt(ctx, ev) {
const cards = bountyCards(ctx, isBandBounty);
if (!cards.length) { if (typeof base.onArt === 'function') base.onArt.call(base, ctx, ev); return; }
const offer = ctx.rng.shuffle(cards.slice()).slice(0, OFFER_SIZE);
const card = ctx.rng.pick(offer);
if (!card || !ctx.addBounty(card)) { ev.error = 'BAD_TARGET'; ev.detail = 'no bounty available'; }
},
}));
// 画卷 — trap_copy_front_char: copy the operator in range (elite status included) and its equipment. The copied items
// are gained unequipped, like any gained item — the hand, overflow temp, destroyed with the usual toast when both are
// full — never put on the copy (PRTS 画卷 备注 "使用后销毁,获得的装备为未装备状态"; until 0.2.2 a copied item that did
// not merge was equipped onto the copy). A copied normal item that completes a pair with an owned one merges at once,
// the golden in the hand (research 04 addendum "两个同名道具(无论是否被装备)会自动合并…并自动返回整备区"). A copy that
// completes a three-copy merge (GitHub #389, PR #390): the promotion returns the copies' equipment to the hand too
// (PRTS 帮助 "在失去该干员(…合并等)…时自动卸除"), so the elite wears nothing and a copied normal item merges with the
// returned original.
wrap(registry, 'chess_item_6_02_m', () => ({
onArt(ctx, ev) {
const target = (ev.targets || []).find((p) => p && p.kind === 'chess');
if (!target) { ev.error = 'BAD_TARGET'; ev.detail = 'no operator in range'; return; }
// snapshot before the gain: a gain that completes a merge consumes the target and empties `target.items`, and an
// item merge detaches the original's copy from `target.items` while we iterate (the built-in's live loop then
// skipped the next item)
const itemIds = (target.items || []).map((it) => it.id);
const copy = ctx.grantChess(target.id, { requirePool: false, source: 'item:chess_item_6_02_m' });
if (!copy) { ev.error = 'HAND_FULL'; return; }
// snapshot first: a merge detaches the original's copy of the item from `target.items` while we iterate
// (the built-in's live loop then skipped the next item)
for (const itemId of (target.items || []).map((it) => it.id)) {
const got = ctx.grantItem(itemId, { source: 'item:chess_item_6_02_m' });
const holder = got ? ctx.piece(copy.uid) : null;
if (got && got.id === itemId && holder && holder.kind === 'chess') ctx.equipDirect(got.uid, holder.uid);
}
for (const itemId of itemIds) ctx.grantItem(itemId, { source: 'item:chess_item_6_02_m' });
},
}));
+6 -1
View File
@@ -74,6 +74,11 @@ export default {
`../shared/tierN.js` are usable too, but same-named helpers are not interchangeable: `num` of tier 1 / 4 also reads
numeric strings, those of tiers 3 / 5 / 6 do not; `toggleBuff`, `talentBb`, `moduleBb`, `instantKind` take different
arguments per tier. Read the helper before using it.
- **Not in a kit**: a module's trait line 「攻击范围内存在N名及以上敌人时攻击速度+X」 (REA-Y: 圣约送葬人, 隐德来希) is applied
to every operator by the engine (`../traitMods.js`, called from `battle/players.js` `_setupUnit` after `kit.install`);
a kit adding it too would count it twice. The other conditional attack-speed lines of modules stay in their kits —
traitMods.js lists them and refuses them, and `test/sim/trait_attack_speed.test.js` scans every chess, 补位 and 自选
loadout so a new record with an unknown shape is noticed.
- **The battle API** documented in docs/SIM.md: units (§2), buffs / mods / statuses (§3), the damage & heal pipeline
(§4), the hook bus and its re-entrancy rule (§5), the engine helpers content must use (§6), the skill runtime (§7.1);
engine modules by relative path (`../../../constants.js`, `targeting.js`, `body.js` for huge enemies, `dir.js` for
@@ -201,7 +206,7 @@ hook), and clear the owner's `mem.summonStock[tokenId]` when the skill takes the
`units: [op, { uid, kind: 'token', tokenId, ownerUid, row, col }]` (`test/content/op_bgsnow.test.js`); the match side is
covered by `test/match/diy-shop.test.js` (a DIY 鸿雪's 打字机 from prep to battle). A 召唤师 whose talent holds a deck of
summons ("可以使用5个召唤物(最多同时部署3个)") takes `shared/summoner.js summonDeck` — the holding, a placed piece's return
on its tile, recalls, its pieces' kits (`op-mgllan.js`, `op-ling.js`, `op-radian.js`). Every pick fights at full potential, as every chess (the owner's decision of 2026-10-07: data/backups.json forms, build-data `OPERATOR_POTENTIAL`). A new kit makes the operator a legal pick at once:
on its tile, recalls, its pieces' kits (`op-mgllan.js`, `op-ling.js`, `op-radian.js`). Every owned pick fights at its player's potential and 练度, as every chess (default 潜能 6 and 精英2 Lv.60 — the owner's decision of 2026-10-08; data/backups.json forms are built at full potential and carry the lower ranks, DATA.md §2.3); a prototype pick has neither. A new kit makes the operator a legal pick at once:
the server's `welcome.diyKitted` lists `KITTED_CHARS`, so the 自选编队 picker offers it and `room.diy` keeps it.
**The fidelity rule and the checklist** above apply item by item: every skill at rank 4 and 7, every talent, every
@@ -28,7 +28,9 @@ export default {
const s2On = (u) => !!(u.skill && u.skill.active && u.skill.id === S2);
return {
skill: { kind: 'passive' },
skills: { [S2]: { kind: 'duration', mods: { atkPct: num(skillBbOf(chess, S2).atk) } } },
// [ASSUMED] S2 never shoots 白铁's 铁钳号 (its damage is cancelled, so it never drops below 80 %): the device alone does
// not open it (skills.js allyTargetsOk)
skills: { [S2]: { kind: 'duration', mods: { atkPct: num(skillBbOf(chess, S2).atk) }, allyTargets: false } },
trait: {
canAttack: (battle, u) => !s2On(u) || battle.enemiesInKeys(u.rangeKeys, u, u.profile).some(huntable),
},
@@ -2,6 +2,7 @@
// Conventions of the tier-2 kits: ../shared/tier2.js; kit contract and rules: ../README.md.
import { num, talentBb, traitBb, onHitBy, alliesInGridOf, toggleBuff } from '../shared/tier1.js';
import { hypot } from '../../../detmath.js';
export default {
// ---------------------------------------------------------------------------------------------------------------
@@ -33,7 +34,7 @@ export default {
const mn = num(tb.min_dist), mx = num(tb.max_dist, 4), ds = num(tb.damage_scale);
onHitBy(battle, unit, ({ target, dmg }) => {
if (!dmg.isAttack) return;
const d = Math.hypot(target.x - unit.x, target.y - unit.y);
const d = hypot(target.x - unit.x, target.y - unit.y);
dmg.amount *= 1 + ds * (mx > mn ? Math.max(0, Math.min(1, (d - mn) / (mx - mn))) : 1);
});
}
@@ -7,6 +7,9 @@ import {
num, defOf, talentBb, moduleTalentBb, selectedId, altSkills, alive, gridKeys, fx, copyGrid, textNum, freeTile,
groundTile, enemiesOn,
} from '../shared/tier3.js';
import { powi } from '../../../detmath.js';
import { champagneHold, CHAMPAGNE_TRIGGER } from '../../tokens.js';
import { canTargetEnemy } from '../../../targeting.js';
/** 琳琅诗怀雅 S3's coin range (PRTS 备注 "前方范围2-4"; range_table "2-4", facing right). */
const SWIRE2_COIN_GRID = Object.freeze([[1, 1], [0, 0], [0, 1], [0, 2], [-1, 1]]);
@@ -72,10 +75,12 @@ export default {
skill: null,
trait: { noAttack: true },
install(battle, bomb) {
champagneHold(battle, bomb); // 禁疗, no HP loss, never gone because of its HP (the owner's decision D2 of 2026-10-08)
battle.on('tick', () => {
if (!bomb.alive || !bomb.deployed) return;
for (const e of battle.enemies) {
if (!e.alive || e.hidden || e.isFlying || e.s.flags.untargetable) continue;
// a ground enemy it may select — no 隐匿 one that is neither revealed nor blocked (tokens.js CHAMPAGNE_TRIGGER)
if (!canTargetEnemy(bomb, e, CHAMPAGNE_TRIGGER)) continue;
if (!bodyOnTile(e, bomb.tileR, bomb.tileC)) continue;
// first enemy touching it; after switchT s on the field the bomb deals its damage one extra time
const hits = battle.time - bomb.deployedAt >= switchT - 1e-9 ? 2 : 1;
@@ -267,7 +272,7 @@ export default {
battle.on('fatal', (ctx) => {
if (ctx.unit !== unit || ctx.prevented) return;
const n = unit.mem.saveCount ?? 0;
const cost = Math.abs(num(t1.cost, -5)) * Math.pow(num(t1.cost_multi, 2), n);
const cost = Math.abs(num(t1.cost, -5)) * powi(num(t1.cost_multi, 2), n);
const pl = battle.getPlayer(unit.ownerId);
if (!pl || pl.dp + 1e-9 < cost) return;
battle.addDp(unit.ownerId, -cost);
@@ -3,6 +3,7 @@
import { PUSH_DIRECTIONAL_MIN_DIST } from '../../../constants.js';
import { num, defOf, talentBb, traitBb, onTiles, gridKeys, fx, copyGrid } from '../shared/tier3.js';
import { hypot } from '../../../detmath.js';
/** Two enemy bodies touch within this distance (tiles) — 见行者 collision stun. */
const COLLIDE = 0.6;
@@ -33,7 +34,7 @@ export default {
// (only the angle: a target nearer than 0.25 tile still turns radial at 受力等级 −2) — by the official
// 力度 − 重量 distance of a 特效 push (PRTS 推与拉 names 见行者's skills 特效类; Battle.push / pushDistance);
// stopped short of it ⇒ it hit a wall
const near = Math.hypot(e.x - unit.x, e.y - unit.y) < PUSH_DIRECTIONAL_MIN_DIST;
const near = hypot(e.x - unit.x, e.y - unit.y) < PUSH_DIRECTIONAL_MIN_DIST;
const expect = battle.pushDistance(e, near ? force - 2 : force, { effect: true });
const moved = battle.push(e, force, { from: unit, dir: { x: unit.fwd[1], y: unit.fwd[0] }, fixedAngle: true, effect: true });
const wall = expect > 0 && moved + 0.05 < expect;
@@ -43,6 +43,7 @@ export default {
skill: null,
trait: { hitsFn: (b, w) => Math.max(1, w.mem.wolves || 1) },
install(battle, w) {
w.mem.wolfCapacity = maxWolves; // (b.snap `wolves`, snapshot.js wolfView: the pips under the HP bar)
const tal = w.def.talents || [];
const wb = tal[0]?.bb ?? {};
const per = num(wb.block_cnt, num(wb['vigil_wolf_t_1_enhance[trigger].block_cnt'], 1));
@@ -30,7 +30,9 @@ export default withDefaults({
return {
skills: alt(def, {
// 铳骑主考官: the next attack is a triple hit of atk_scale each and reloads `charge` bullet(s) into ONE other
// 拉特兰 operator around him (8 tiles, [ASSUMED] nearest first) whose ammo skill runs
// 拉特兰 operator around him whose ammo skill runs — PRTS 备注 「补弹范围为周围8格,仅选择可补弹的干员为目标,优先选择最后
// 部署的干员」: the latest deployment first (battle-wide deploySeq: a redeploy is a new deployment). Until 0.2.2 the
// nearest first [ASSUMED] (GitHub #325, PR #329)
skchr_rmixer_1: () => ({
kind: instantKind(def),
attack: {
@@ -39,7 +41,7 @@ export default withDefaults({
const n = num(bb.charge, 1);
const cand = battle.alliesInRadius(unit.x, unit.y, 1.5, unit.ownerId)
.filter((a) => a !== unit && a.kind === 'op' && nationOf(a) === 'laterano' && a.skill && a.skill.active && a.skill.kind === 'ammo')
.sort((a, b) => Math.hypot(a.x - unit.x, a.y - unit.y) - Math.hypot(b.x - unit.x, b.y - unit.y) || a.id - b.id);
.sort((a, b) => b.deploySeq - a.deploySeq || a.id - b.id);
if (!cand[0] || !(n > 0)) return;
cand[0].skill.addAmmo(n);
battle.fx('reload', { x: cand[0].x, y: cand[0].y, id: cand[0].id, n });
@@ -49,7 +49,7 @@ export default withDefaults({
unit.mem.sentry = null;
unit.mem.inesSentryAt = null;
const ax = from.x, ay = from.y, bx = unit.x, by = unit.y;
const L2 = (bx - ax) ** 2 + (by - ay) ** 2;
const L2 = (bx - ax) * (bx - ax) + (by - ay) * (by - ay);
const w = num(bb.projectile_range, 1.4);
const hits = [];
for (const e of battle.aliveEnemies()) {
@@ -5,6 +5,7 @@ import { COLS } from '../../../constants.js';
import {
num, tbb, grid, targetsInRange, targetsInGrid, toggleBuff, skillActive, withDefaults,
} from '../shared/tier4.js';
import { hypot } from '../../../detmath.js';
/**
* Free tile for a summon/device: in the rect, standable, not reserved (Battle.isReservedTile: empty, no knocked-out
@@ -39,7 +40,7 @@ export default withDefaults({
for (const k of unit.rangeKeys || []) {
const r = (k / COLS) | 0, c = k % COLS;
if (!freeTile(battle, r, c)) continue;
const d = Math.hypot(c - ref.x, r - ref.y) + (battle.grid.groundPassable(r, c) ? 0 : 5);
const d = hypot(c - ref.x, r - ref.y) + (battle.grid.groundPassable(r, c) ? 0 : 5);
if (d < bd - 1e-9) { bd = d; best = [r, c]; }
}
if (!best) return;
@@ -3,6 +3,7 @@
import { bodyInKeys } from '../../../body.js';
import { num, tbb, keySet, skillActive, isSel, alt, instantKind, withDefaults } from '../shared/tier4.js';
import { hypot } from '../../../detmath.js';
/** 阿罗玛's 非首次标记 (talent 起泡性能测试): one per enemy, shared by every 阿罗玛 (PRTS 备注). */
const AROMA_MARK = 'aroma:bubbled';
@@ -71,7 +72,7 @@ export default withDefaults({
const lo = num(tb.min_dist, 0), hi = num(tb.max_dist, 4);
battle.on('hit', (c) => {
if (c.source !== unit || !c.dmg.isAttack) return;
const d = Math.hypot(c.target.x - unit.x, c.target.y - unit.y);
const d = hypot(c.target.x - unit.x, c.target.y - unit.y);
c.dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo)));
}, { owner: unit });
}
@@ -6,19 +6,23 @@ import {
AURA, num, tbb, moduleBb, grid, batFlat, enemyHasTag, targetsInRange, whileDeployed, pulse, alt, instantKind,
pullToFront, withDefaults,
} from '../shared/tier4.js';
import { hypot } from '../../../detmath.js';
const ABYSSAL = new Set(['char_143_ghost', 'char_263_skadi', 'char_474_glady', 'char_4145_ulpia', 'char_1023_ghost2']);
const isAbyssal = (u) => u?.def?.raw?.groupId === 'abyssal' || ABYSSAL.has(u?.def?.charId ?? u?.def?.raw?.charId);
export default withDefaults({
// ===== 歌蕾蒂娅 (hookmaster) S3 缺水的碎漩狂舞 — bind a far target, tornado: slow, 85 % arts pulses + pull, final pull
// ===== 歌蕾蒂娅 (hookmaster) S3 缺水的碎漩狂舞 — bind a far target, tornado (r 1.5): slow, 85 % arts pulses + pull,
// final pull (the 捕网, r 1)
// S1 缺水的大洋裂断 (charges: next attack pulls the target to her front, 150 %/180 %); S2 缺水的掌握怒海 (BAT +0.5 s,
// wider range, ≤ 2 targets — blocked first — at 135 %/150 % and pulled to her front); module HOK-Y (淡金坠饰):
// a pull towards herself of an enemy farther than 2.5 tiles is one force level stronger
chess_char_4_12_a: (bb, chess, def) => {
const t0 = tbb(def, 0), t1 = tbb(def, 1), mb = moduleBb(def);
const tb = def.traitBb || {};
const R = 1.5; // [ASSUMED] tornado radius (no blackboard key)
// S3 radii (no blackboard key) — PRTS 备注: 「龙卷风半径1.5」 (slow, pulses) and 「技能结束时的拖拽为捕网,捕网半径为1,无伤害」
// (the skill-end pull; until 0.2.2 it reused the tornado's 1.5 — GitHub #324, PR #329)
const R = 1.5, NET_R = 1;
const force = num(bb.force, num(bb['attack@force'], 0));
const iv = Math.max(0.1, num(bb.interval, 1.5));
const g = grid(def.skill?.rangeGrid);
@@ -28,7 +32,7 @@ export default withDefaults({
};
// module HOK-Y: "向自身拖拽较远的敌人时力度提升一个等级"
const farR = num(mb['skill@range_radius'], num(mb['attack@range_radius'], 0)), farF = num(mb['skill@delta_force'], num(mb['attack@delta_force'], 0));
const selfForce = (unit, e, f) => (farR > 0 && farF > 0 && Math.hypot(e.x - unit.x, e.y - unit.y) > farR + 1e-9 ? f + farF : f);
const selfForce = (unit, e, f) => (farR > 0 && farF > 0 && hypot(e.x - unit.x, e.y - unit.y) > farR + 1e-9 ? f + farF : f);
const pullSelf = (battle, unit, e, f) => { if (e && e.alive) dragDmg(battle, unit, e, pullToFront(battle, unit, e, selfForce(unit, e, f))); };
return {
skills: alt(def, {
@@ -75,7 +79,7 @@ export default withDefaults({
const T = unit.mem.tornado;
unit.mem.tornado = null;
if (!T || reason === 'death' || !unit.alive) return;
for (const e of battle.foesInRadius(T.x, T.y, R)) pullSelf(battle, unit, e, force);
for (const e of battle.foesInRadius(T.x, T.y, NET_R)) pullSelf(battle, unit, e, force); // the 捕网
battle.fx('pull', { x: T.x, y: T.y, id: unit.id });
},
},
@@ -6,6 +6,7 @@ import { bodyInKeys } from '../../../body.js';
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
import { isHpLoss } from '../../../damage.js';
import { num, tbb, whileDeployed, toggleBuff, skillActive, isSel, alt, withDefaults } from '../shared/tier4.js';
import { hypot } from '../../../detmath.js';
const LINE = Object.freeze(Array.from({ length: COLS }, (_, i) => Object.freeze([0, i])));
const tileKey = (u) => Math.round(u.y) * COLS + Math.round(u.x);
@@ -83,7 +84,7 @@ export default withDefaults({
battle.on('hit', (c) => {
if (c.source !== unit || !c.dmg.isAttack) return;
if (S3 && skillActive(unit) && unit.baseRangeKeys && !bodyInKeys(c.target, unit.baseRangeKeys)) c.dmg.mul *= num(bb.damage_scale, 1.25);
if (ds > 0) { const d = Math.hypot(c.target.x - unit.x, c.target.y - unit.y); c.dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo))); }
if (ds > 0) { const d = hypot(c.target.x - unit.x, c.target.y - unit.y); c.dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo))); }
}, { owner: unit });
},
};
@@ -37,6 +37,8 @@ export default withDefaults({
skills: alt(def, {
skchr_billro_1: () => ({
kind: 'duration',
// [ASSUMED] a charged cast makes no attack at all: 白铁's 铁钳号 alone does not open it (skills.js allyTargetsOk)
allyTargets: false,
mods: { atkPct: num(bb.atk), defPct: num(bb.def) },
onStart({ battle, unit, skill }) {
unit.mem.billroCharged = isCharged(skill);
@@ -2,7 +2,7 @@
// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md.
import {
num, on, talent, batPct, mods, inFaction, isOp, selectedId, lazySkills, skillRange, crowdAspd,
num, on, talent, batPct, mods, inFaction, isOp, selectedId, lazySkills, skillRange,
} from '../shared/tier5.js';
const isLaterano = (u) => inFaction(u, 'lateranoShip', ['laterano']);
@@ -100,7 +100,9 @@ export default {
} },
],
install(battle, unit) {
crowdAspd(battle, unit, 'excu2:module', num(tm.attack_speed), num(tm['trigger_cnt[equip]']));
// REA-Y "攻击范围内存在2名及以上敌人时攻击速度+12": the ENGINE owns this trait line now
// (server/sim/content/traitMods.js, applied from battle/players.js _setupUnit for every operator) — a second
// implementation here would count the same 12 points twice.
if (sid === 'skchr_excu2_3') { // S3: +attack@atk ATK per bullet spent
battle.on('ammoUsed', (c) => {
if (c.unit !== unit || !unit.skill?.active) return;
@@ -3,7 +3,7 @@
import { mitigate } from '../../../damage.js';
import {
RING1, num, on, talent, talentRec, traitBb, skillGrid, mods, dist, lazySkills, instantKind, crowdAspd,
RING1, num, on, talent, talentRec, skillGrid, mods, dist, lazySkills, instantKind,
} from '../shared/tier5.js';
/** 隐德来希 S3 "每次攻击对心烛至少造成35%攻击力的伤害". */
@@ -23,7 +23,7 @@ export default {
// for atk_scale × ATK phys every `interval` s — ground enemies only unless the carrier has taken off (PRTS 备注).
// Module REA-Y (elite): ASPD +12 with ≥ 2 enemies in range.
chess_char_5_06_a: (bb, chess, def) => {
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess);
const t0 = talent(chess, 0), t1 = talent(chess, 1);
const hpScale = num(bb['attack@max_hp_scale'], 0.6), defScale = num(bb['attack@def_scale'], 1), resScale = num(bb['attack@magic_resistance_scale'], 1);
const nCandles = Math.max(1, num(bb['attack@max_target'], 3));
const candleKey = talentRec(chess, 2)?.bbStr?.take_extra_enemy_key ?? CANDLE_KEY;
@@ -140,7 +140,9 @@ export default {
} },
],
install(battle, unit) {
crowdAspd(battle, unit, 'etlchi:module', num(tb.attack_speed), num(tb.cnt));
// REA-Y "攻击范围内存在2名及以上敌人时攻击速度+12": the ENGINE owns this trait line now
// (server/sim/content/traitMods.js, applied from battle/players.js _setupUnit for every operator) — a second
// implementation here would count the same 12 points twice.
const mine = (e) => e && e.mem && e.mem.candleOwner === unit;
// "心烛只受隐德来希攻击的影响": other sources' element gauges never fill a candle either
battle.on('elementHit', (c) => { if (mine(c.target) && c.source !== unit) c.dmg.cancel = true; }, { owner: unit });
@@ -4,6 +4,7 @@
import { COLS } from '../../../constants.js';
import { rotateOffset } from '../../../dir.js';
import { AURA_IV, AURA_DUR, num, talent, traitBb, skillGrid, isOp, leaderOf, whileOn } from '../shared/tier5.js';
import { hypot, sin, cos } from '../../../detmath.js';
/** 魔王 T1 微尘: collision radius of one mote (PRTS 备注 0.4); orbit radius / speed come from the talent bb. */
const MOTE_HIT_RADIUS = 0.4;
@@ -82,11 +83,11 @@ export default {
const ang = spd * t + (2 * Math.PI * k) / cnt;
// the orbit turns with his direction: angle 0 = straight ahead, π/2 = his left hand
const [fr, fc] = unit.fwd, [lr, lc] = rotateOffset(1, 0, unit.dir);
const mx = unit.x + orbit * (fc * Math.cos(ang) + lc * Math.sin(ang)), my = unit.y + orbit * (fr * Math.cos(ang) + lr * Math.sin(ang));
const mx = unit.x + orbit * (fc * cos(ang) + lc * sin(ang)), my = unit.y + orbit * (fr * cos(ang) + lr * sin(ang));
let hit = null, hd = Infinity;
for (const a of cands) {
if (a.findBuff('cetsyr:mote')) continue;
const d = Math.hypot(a.x - mx, a.y - my);
const d = hypot(a.x - mx, a.y - my);
if (d <= MOTE_HIT_RADIUS + 1e-9 && d < hd) { hd = d; hit = a; }
}
if (!hit) continue;
@@ -8,6 +8,7 @@ import {
AURA_IV, AURA_DUR, RING1, num, on, talent, skillGrid, maxCharges, mods, dist, isOp, selectedId, lazySkills,
instantKind, whileOn, permBuff,
} from '../shared/tier5.js';
import { hypot } from '../../../detmath.js';
/** Straight-road length through each tile (max of the horizontal and vertical runs of ground-passable tiles). */
const ROAD_CACHE = new WeakMap();
@@ -36,12 +37,16 @@ function straightRun(battle, r, c) {
export default {
// ---------------------------------------------------------------------------------------------------------------
// 引星棘刺 — S2 解构涌潮 (instant / 2 charges elite): throws an alchemy unit at the target: for 12/15 s ground enemies
// around it get healing ×0.5, take 120/140 % ATK arts per second, allies heal 12/15 % ATK per second; the unit drifts
// around it get healing ×0.5, take 120/140 % ATK arts per second, allies recover 12/15 % ATK per second; the unit drifts
// along the throw direction and its radius grows. T1 心相: ATK +10 %, +3 s when another op is in range.
// T2 视界: allies ASPD +5, enemies −5 (doubled on straight roads ≥ 6 tiles). Module (elite): +0.1 SP/s with a unit out.
// S1 度算浪波 (instant): an alchemy unit thrown at the ally in her range with the lowest HP ratio: for
// projectile_delay_time s (+3 s 心相) allies on the landing tile and the 8 around it get DEF +def and heal
// hp_recovery_per_sec_ratio × ATK per second. S3 “我的海疆”: passive — her range is the skill range; active
// projectile_delay_time s (+3 s 心相) allies on the landing tile and the 8 around it get DEF +def and recover
// hp_recovery_per_sec_ratio × ATK per second. The recovery of S1 / S2 ("每秒回复/恢复相当于攻击力…的生命") is 生命回复速度,
// an hpRegen buff per alchemy unit set at each 1 s pulse until the next — the wording and blackboard key of 锡人 S2, whose
// PRTS 备注 says "生命恢复的提供方式为增加目标的“生命回复速度”属性,不受治疗加成和禁疗影响" (the only other 炼金师): 不屈者 and
// 禁疗 units recover too (community report: 炼金单元无法给不屈者等禁疗的干员提供生命恢复; it was a heal up to 0.2.1).
// S3 “我的海疆”: passive — her range is the skill range; active
// (instant) — alchemy units on the max_target_token operators with the lowest block count: for projectile_delay_time s
// the enemies around each of them (RING1, following it) get ATK/DEF/RES −, one strongest instance (不叠加), and take
// atk_scale × ATK arts per second, everything ramping +per_interval each `interval` s up to max_stack_cnt steps.
@@ -62,11 +67,14 @@ export default {
if (z.type === 'guard') {
const inZone = (a) => Math.abs(a.tileR - z.r) <= 1 && Math.abs(a.tileC - z.c) <= 1;
const allies = battle.alliesFor(unit).filter(inZone);
for (const a of allies) battle.addBuff(a, { key: 'thorn2:bastion', duration: AURA_DUR, mods: mods({ defFlat: num(bb.def) }) });
// one DEF buff per alchemy unit, so two units on one ally add up (+60 +60; PRTS 度算浪波 备注 「效果均可叠加」,
// GitHub #388 — one shared key let the last unit's buff replace the other's)
z.defKey ??= `thorn2:bastion:${unit.id}:${unit.mem.bastionSeq = (unit.mem.bastionSeq ?? 0) + 1}`;
for (const a of allies) battle.addBuff(a, { key: z.defKey, duration: AURA_DUR, mods: mods({ defFlat: num(bb.def) }) });
if (z.acc >= 1 - 1e-9) {
z.acc -= 1;
const heal = unit.s.atk * num(bb.hp_recovery_per_sec_ratio);
if (heal > 0) for (const a of allies) if (a.hp < a.s.maxHp) battle.heal(unit, a, heal, { aura: true });
const regen = unit.s.atk * num(bb.hp_recovery_per_sec_ratio); // 生命回复速度 (see the header)
if (regen > 0) for (const a of allies) battle.addBuff(a, { key: z.key, duration: 1 + 2 * battle.dt, source: unit, mods: { hpRegen: regen } });
}
return;
}
@@ -92,9 +100,12 @@ export default {
// (GitHub #124 「引星棘刺一技能不会在满技力时自动释放」)
trigger: { rule: 'SP_FULL' },
onStart({ battle, unit }) {
const t = battle.alliesInGrid(unit).filter((a) => a.hp > 0).sort((a, b) => a.hpRatio - b.hpRatio || b.blocking.length - a.blocking.length || dist(a, unit) - dist(b, unit) || a.id - b.id)[0];
// PRTS 备注 「※优先选择生命比例最低>最晚部署的我方单位(不含装置职业单位)」: the lowest HP ratio, a tie to the
// latest deployment (alliesInGrid has no devices); until 0.2.2 a tie went to more blocking, then the nearer
const t = battle.alliesInGrid(unit).filter((a) => a.hp > 0).sort((a, b) => a.hpRatio - b.hpRatio || b.deploySeq - a.deploySeq || a.id - b.id)[0];
if (!t) return;
const z = { type: 'guard', r: t.tileR, c: t.tileC, x: t.x, y: t.y, t: 0, acc: 0, dur: num(bb.projectile_delay_time, 6) + extend(battle, unit) };
const key = `thorn2:guard:${unit.id}:${unit.mem.zoneSeq = (unit.mem.zoneSeq ?? 0) + 1}`; // its regen buff ("效果均可叠加")
const z = { type: 'guard', r: t.tileR, c: t.tileC, x: t.x, y: t.y, t: 0, acc: 0, dur: num(bb.projectile_delay_time, 6) + extend(battle, unit), key };
(unit.mem.zones ??= []).push(z);
battle.fx('zone', { x: z.x, y: z.y, id: unit.id, r: RING1, duration: z.dur });
},
@@ -115,10 +126,13 @@ export default {
}),
skill: {
kind: maxCharges(chess, def) > 1 ? 'charges' : 'instant',
// 白铁's 铁钳号·原型机 (a registered ally target) is a target like an enemy when no enemy is in her range — the owner's
// rule of 2026-10-08: the unit lands on it and its pulses hit it (cancelled by its kit) [ASSUMED] (skills.js allyTargetsOk)
allyTargets: true,
onStart({ battle, unit }) {
const list = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile);
sortEnemyTargets(battle, unit, list, null);
let t = list.find((e) => !e.isFlying) ?? list[0];
let t = list.find((e) => !e.isFlying) ?? list[0] ?? battle.allyTargetsInKeys(unit.rangeKeys, unit)[0];
if (!t) {
// PRTS 备注: no enemy in range → thrown at the farthest tile straight ahead inside her range
let best = null;
@@ -134,7 +148,7 @@ export default {
}
const extra = battle.alliesInGrid(unit).some((a) => a !== unit && isOp(a)) ? Math.min(num(t0.projectile_extend), num(t0.projectile_extend_max, Infinity)) : 0;
// it drifts away from her deployment tile ("移动方向始终为远离棘刺部署位置中心的方向")
const dx = t.x - unit.x, dy = t.y - unit.y, len = Math.hypot(dx, dy) || 1;
const dx = t.x - unit.x, dy = t.y - unit.y, len = hypot(dx, dy) || 1;
// `key`: the fx re-sent every second as the unit drifts and grows is this one zone — the client updates it in
// place instead of stacking a new layer each second (render/fx/zones.js; community report of 2026-10-06)
const key = `thorn2:${unit.id}:${unit.mem.zoneSeq = (unit.mem.zoneSeq ?? 0) + 1}`;
@@ -185,8 +199,14 @@ export default {
if (z.acc >= 1 - 1e-9) {
z.acc -= 1;
for (const e of foes) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'alchemy'] });
const heal = unit.s.atk * num(bb.hp_recovery_per_sec_ratio_chr);
if (heal > 0) for (const a of battle.alliesInRadius(z.x, z.y, r, null)) if (a.hp < a.s.maxHp) battle.heal(unit, a, heal, { aura: true });
// (白铁's 铁钳号 too, like a ground enemy — its kit cancels the hit)
for (const a of battle.allyTargetsInRadius(z.x, z.y, r, unit)) battle.dealDamage(unit, a, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'alchemy'] });
const regen = unit.s.atk * num(bb.hp_recovery_per_sec_ratio_chr); // 生命回复速度 (see the header)
if (regen > 0) {
for (const a of battle.alliesInRadius(z.x, z.y, r, null)) {
if (battle.allySelectable(a, unit)) battle.addBuff(a, { key: `${z.key}:regen`, duration: 1 + 2 * battle.dt, source: unit, mods: { hpRegen: regen } });
}
}
battle.fx('zone', { x: z.x, y: z.y, id: unit.id, r, duration: Math.max(0, z.dur - z.t), key: z.key });
}
}
@@ -39,6 +39,9 @@ export default {
}),
skill: {
kind: 'toggle',
// [ASSUMED] its attacks reach the enemies he blocks; the unblockable 铁钳号 of 白铁 never: it does not open it (skills.js
// allyTargetsOk)
allyTargets: false,
mods: mods({ atkPct: num(bb.atk), defPct: num(bb.def), blockCnt: num(bb.block_cnt), hpRegenRatio: num(bb.hp_recovery_per_sec_by_max_hp_ratio) }),
targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined,
attack: { hitAllBlocked: true },
@@ -2,6 +2,7 @@
// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md.
import { COLS } from '../../../constants.js';
import { attackCountdown } from '../../../ai.js';
import { toLocal } from '../../../dir.js';
import { num, talent, skillGrid, batPct, mods, lazySkills, whileOn } from '../shared/tier5.js';
@@ -33,7 +34,7 @@ export default {
attack: { noAttack: true },
onStart({ unit }) { unit.mem.iceCd = 0; unit.mem.iceRow = 0; },
onTick({ battle, unit, dt }) {
unit.mem.iceCd -= dt;
unit.mem.iceCd = attackCountdown(unit.mem.iceCd, dt); // (the engine's attack countdown, PR #402)
if (unit.mem.iceCd > 0 || !unit.canAct || unit.s.flags.disarm) return;
if (!battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).length) return;
unit.mem.iceCd = unit.s.interval;
@@ -58,7 +58,11 @@ function lemuen(bb, chess, def) {
const d2 = num(bb['attack@dist_2'], num(bb['attack@projectile_range'], 1.5));
const s1 = num(bb['attack@proj_atk_scale_1'], 1), s2 = num(bb['attack@proj_atk_scale_2'], 1);
const pickLock = (battle, unit, locks) => {
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile);
let cands = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile);
// 白铁's 铁钳号·原型机 (a registered ally target, an enemy-camp summon) is locked like an enemy when no enemy is in her
// range — the owner's rule of 2026-10-08 (it draws aggro like an enemy): her bullets are spent on it, the shells' hits
// are cancelled by it [ASSUMED: after every enemy, its 嘲讽等级 −2]
if (!cands.length) cands = battle.allyTargetsInKeys(unit.rangeKeys, unit);
if (!cands.length) return null;
sortEnemyTargets(battle, unit, cands, 'lowDef');
const cnt = (e) => locks.reduce((n, L) => n + (L.e === e ? 1 : 0), 0);
@@ -85,6 +89,10 @@ function lemuen(bb, chess, def) {
const d = bodyDist(e, x, y);
battle.dealDamage(unit, e, { amount: atk * (d <= d1 + 1e-9 ? s1 : s2), type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'bombard'] });
}
// (the 铁钳号 too, like an enemy — its kit cancels the hit)
for (const a of battle.allyTargetsInRadius(x, y, d2, unit)) {
battle.dealDamage(unit, a, { amount: atk * s2, type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'bombard'] });
}
};
const bombard = (battle, unit, locks) => {
const atk = unit.s.atk;
@@ -164,6 +172,7 @@ function lemuen(bb, chess, def) {
kind: 'ammo',
ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 5))),
attack: { noAttack: true },
allyTargets: true, // its locks take 白铁's 铁钳号 too (pickLock) — skills.js allyTargetsOk
onStart({ unit }) { unit.mem.lemLocks = []; unit.mem.lemAcc = aim; },
onTick({ battle, unit, skill, dt }) {
const m = unit.mem;
@@ -4,6 +4,7 @@
import { COLS } from '../../../constants.js';
import { bodyInKeys } from '../../../body.js';
import { num, tbb, moduleBb, live, keyOf, enemiesIn, isTok, instantKind, AROUND8, aura } from '../shared/tier6.js';
import { hypot } from '../../../detmath.js';
// ------------------------------------------------------------------------------------------------------------------
// 圣聆初雪 chess_char_6_02 (阵法术师) — S3 群山俯首; 无垠的雪景; 圣山的祝福; module 千分之一的心
@@ -217,7 +218,7 @@ function lureGoal(battle, unit, e) {
for (const [dr, dc] of AROUND8) {
const r = unit.tileR + dr, c = unit.tileC + dc;
if (!battle.grid.groundPassable(r, c)) continue;
const d = Math.hypot(e.x - c, e.y - r);
const d = hypot(e.x - c, e.y - r);
if (d < bd - 1e-9) { bd = d; goal = [r, c]; }
}
return goal;
@@ -51,6 +51,8 @@ function yu(bb, chess, def) {
// 'teleport' fx). PRTS's 0.13 s between the damage and the teleport is not modelled (same tick).
skchr_yu_2: {
kind: 'duration',
// [ASSUMED] its burst and teleport pick enemies; 白铁's 铁钳号 alone does not open it (skills.js allyTargetsOk)
allyTargets: false,
mods: { hpPct: num(bb.max_hp), atkPct: num(bb.atk), blockCnt: num(bb.block_cnt) },
attack: { dmgType: 'arts' },
onStart({ battle, unit }) {
@@ -79,6 +81,7 @@ function yu(bb, chess, def) {
skill: {
kind: 'duration',
mods: { hpPct: num(bb.max_hp), atkPct: num(bb.atk), defPct: num(bb.def) },
allyTargets: false, // [ASSUMED] its wall burns enemies crossing it; 白铁's 铁钳号 alone does not open it
// the wall runs through his tile perpendicular to his direction (RIGHT / LEFT: his column; UP / DOWN: his row —
// it turns with the deploy direction like a range) [ASSUMED orientation for UP / DOWN]
onStart({ battle, unit }) {
@@ -5,11 +5,17 @@ import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
import { CHAIN_RADIUS } from '../../../constants.js';
import { bodyDist, bodyInKeys } from '../../../body.js';
import { num, bv, tbb, live, ANY, enemiesIn, batOf, N4, aura } from '../shared/tier6.js';
import { powi } from '../../../detmath.js';
// ------------------------------------------------------------------------------------------------------------------
// 异客 chess_char_6_05 (链术师) — S3 辉煌裂片; 机理分析; 孤卒
const STORM_RADIUS = 1.5; // [ASSUMED] storm zone radius (not in data)
/** S3 雷暴区域: PRTS 备注 「以其判定中心所在格为中心生成一个x-1的雷暴区域」 — range x-1 (range_table: the diamond of radius 2,
* 13 tiles) around the tile of the target's centre (GitHub #322, PR #329). Until 0.2.2 a 1.5-tile circle around the
* target's exact position [ASSUMED]. The chain of each strike bounces on its own radius, inside or outside the zone. */
const STORM_GRID = Object.freeze([[2, 0], [1, -1], [1, 0], [1, 1], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, -1], [-1, 0], [-1, 1], [-2, 0]]);
/** The storm fx's ring radius (tiles): the diamond's reach — drawn only, the zone is STORM_GRID. */
const STORM_FX_RADIUS = 2;
function pasngr(bb, chess, def) {
const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {};
@@ -25,7 +31,7 @@ function pasngr(bb, chess, def) {
for (let i = 0; i < count && prev; i++) {
hit.add(prev.id);
battle.fx('lightning', { x: prev.x, y: prev.y, id: prev.id, src: unit.id });
battle.dealDamage(unit, prev, { amount: unit.s.atk * scale * Math.pow(1 - num(ch.falloff, 0.15), i), type: 'arts', isSkill: true, isAttack: true, tags: ['skill', 'storm'] });
battle.dealDamage(unit, prev, { amount: unit.s.atk * scale * powi(1 - num(ch.falloff, 0.15), i), type: 'arts', isSkill: true, isAttack: true, tags: ['skill', 'storm'] });
if (slug > 0 && prev.alive) battle.applyStatus(prev, 'sluggish', { duration: slug, source: unit });
let best = null, bd = Infinity;
for (const x of battle.foesInRadius(prev.x, prev.y, ch.radius || CHAIN_RADIUS)) {
@@ -68,16 +74,18 @@ function pasngr(bb, chess, def) {
const cands = enemiesIn(battle, unit, keys);
if (!cands.length) return;
const tgt = cands.reduce((a, b) => (b.hp > a.hp ? b : a));
const cx = tgt.x, cy = tgt.y;
// 判定中心所在格: the tile of the target's centre (the rounding of body.js posKey); fixed for the storm's 4 s
const cx = Math.round(tgt.x), cy = Math.round(tgt.y);
const zoneKeys = absoluteRangeKeys(STORM_GRID, cy, cx, 1, 0);
const dur = num(bb.duration, 4), iv = Math.max(0.1, num(bb.interval, 0.5)), scale = num(bb.atk_scale, 1);
const n = Math.max(1, Math.round(dur / iv));
battle.fx('storm', { x: cx, y: cy, id: unit.id, duration: dur, r: STORM_RADIUS });
battle.fx('storm', { x: cx, y: cy, id: unit.id, duration: dur, r: STORM_FX_RADIUS });
let k = 0;
const seq = unit.deploySeq;
const h = battle.every(iv, () => {
if (!live(unit) || unit.deploySeq !== seq) { h.cancel(); return; } // the storm ends when she leaves the field
if (++k >= n) h.cancel();
const zone = battle.foesInRadius(cx, cy, STORM_RADIUS).filter((e) => canTargetEnemy(unit, e, ANY));
const zone = enemiesIn(battle, unit, zoneKeys); // every enemy she can select whose body is on the 13 tiles
const e = battle.rng.pick(zone);
if (e) strike(battle, unit, e, scale);
}, { owner: unit });
@@ -31,6 +31,8 @@ function rosmon(bb, chess, def) {
// two 战术装备 on melee tiles of her range (the token kit: appear stun, blocked enemies DEF −160, lifetime)
skchr_rosmon_3: {
kind: 'duration',
// [ASSUMED] blocked enemies only; 白铁's unblockable 铁钳号 never: it does not open it (skills.js allyTargetsOk)
allyTargets: false,
mods: { batPct: batOf(bb.base_attack_time, def, true), atkPct: num(bb.atk) },
targeting: { maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 2))) },
onStart({ battle, unit }) {
@@ -5,6 +5,7 @@ import { sortEnemyTargets, canTargetEnemy } from '../../../targeting.js';
import { rotateOffset } from '../../../dir.js';
import { bodyDist } from '../../../body.js';
import { num, tbb, hasBond, ANY, selectedSkill, skillGridOf, WHOLE_FIELD, bstate, aura } from '../shared/tier6.js';
import { hypot, sin, cos, atan2 } from '../../../detmath.js';
// ------------------------------------------------------------------------------------------------------------------
// 荒芜拉普兰德 chess_char_6_18 (驭械术师) — S3 终幕·浩劫; 头狼; 叙拉古的荣幸
@@ -39,10 +40,10 @@ function whitw2(bb, chess, def) {
// ① `k` drones leave her evenly spread, the first along her facing (row 0 is the bottom row: angles in the (col, row)
// plane, counter-clockwise)
const releaseDrones = (unit, k) => {
const a0 = Math.atan2(unit.fwd[0], unit.fwd[1]);
const a0 = atan2(unit.fwd[0], unit.fwd[1]);
for (let i = 0; i < k; i++) {
const a = a0 + (2 * Math.PI * i) / k;
unit.mem.drones.push({ x: unit.x, y: unit.y, hx: Math.cos(a), hy: Math.sin(a), v: WHITW2_SPREAD.v0, age: 0, phase: 'spread', t: null, cd: 0, rampId: null, ramp: 0, orbit: null });
unit.mem.drones.push({ x: unit.x, y: unit.y, hx: cos(a), hy: sin(a), v: WHITW2_SPREAD.v0, age: 0, phase: 'spread', t: null, cd: 0, rampId: null, ramp: 0, orbit: null });
}
};
// speed v → v + acc·dt (capped); the distance covered at the mean of the two (exact under constant acceleration)
@@ -56,7 +57,7 @@ function whitw2(bb, chess, def) {
let best = null, bd = Infinity, bh = Infinity;
for (const e of battle.enemies) {
if (!ok(e)) continue;
const de = Math.hypot(e.x - d.x, e.y - d.y), dh = Math.hypot(e.x - unit.x, e.y - unit.y);
const de = hypot(e.x - d.x, e.y - d.y), dh = hypot(e.x - unit.x, e.y - unit.y);
if (de < bd - 1e-9 || (de <= bd + 1e-9 && dh < bh - 1e-9)) { best = e; bd = de; bh = dh; }
}
return best;
@@ -65,9 +66,9 @@ function whitw2(bb, chess, def) {
const circle = (d, dt) => {
const { r, v } = WHITW2_ORBIT;
if (!d.orbit) d.orbit = { cx: d.x - d.hy * r, cy: d.y + d.hx * r };
const a = Math.atan2(d.y - d.orbit.cy, d.x - d.orbit.cx) + (v / r) * dt;
d.x = d.orbit.cx + r * Math.cos(a); d.y = d.orbit.cy + r * Math.sin(a);
d.hx = -Math.sin(a); d.hy = Math.cos(a);
const a = atan2(d.y - d.orbit.cy, d.x - d.orbit.cx) + (v / r) * dt;
d.x = d.orbit.cx + r * cos(a); d.y = d.orbit.cy + r * sin(a);
d.hx = -sin(a); d.hy = cos(a);
};
// one tick of one drone: spread → (pick) → chase → on the target, attacking like a normal drone. [ASSUMED] its attack
// clock runs all the time (one attack per interval of hers at most, whatever it chased in between) and its first hit
@@ -103,7 +104,7 @@ function whitw2(bb, chess, def) {
if (d.phase === 'chase') {
const s = accelerate(d, WHITW2_CHASE, dt);
if (bodyDist(t, d.x, d.y) > s + 1e-9) {
const dx = t.x - d.x, dy = t.y - d.y, L = Math.hypot(dx, dy);
const dx = t.x - d.x, dy = t.y - d.y, L = hypot(dx, dy);
if (L > 1e-9) { d.hx = dx / L; d.hy = dy / L; }
d.x += d.hx * s; d.y += d.hy * s;
return;
@@ -127,6 +128,8 @@ function whitw2(bb, chess, def) {
// grid only selects those targets — no rangeId, no 攻击范围 in the text — so the card keeps her 3-1 (showOwnRange)
skchr_whitw2_1: {
kind: 'toggle',
// [ASSUMED] her drones lock enemies only (install): 白铁's 铁钳号 alone does not open S1 / S2 (skills.js allyTargetsOk)
allyTargets: false,
mods: { atkPct: num(bb.atk) },
targeting: { rangeGrid: WHOLE_FIELD, showOwnRange: true },
onStart({ unit }) { unit.mem.lazyLock = null; },
@@ -135,6 +138,7 @@ function whitw2(bb, chess, def) {
// (install); each drone hit ramps on its own target (trait init → max) and fears it attack@fear s with attack@prob
skchr_whitw2_2: {
kind: 'duration',
allyTargets: false,
mods: { atkPct: num(bb.atk) },
...(skillGridW ? { targeting: { rangeGrid: skillGridW } } : {}),
attack: {
+2 -1
View File
@@ -63,6 +63,7 @@ import { bodyInKeys } from '../../../body.js';
import { hasHp } from '../../../damage.js';
import { RESIST_STATUSES } from '../../../buffs.js';
import { TICK } from '../../../constants.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_aglna2_1';
const S2 = 'skchr_aglna2_2';
@@ -342,7 +343,7 @@ export default {
if (!st || !skillOn(unit, S3) || !up(unit) || st.flight || !unit.canAct || unit.blocking.length) return;
const tgt = moveTarget(battle, unit);
if (!tgt) return;
const dur = Math.hypot(tgt.r - unit.tileR, tgt.c - unit.tileC) / MOVE_SPEED;
const dur = hypot(tgt.r - unit.tileR, tgt.c - unit.tileC) / MOVE_SPEED;
const act = unit.skill.activations;
st.flight = tgt;
battle.releaseBlocked(unit);
+7
View File
@@ -114,9 +114,13 @@ export default {
},
[S2]: {
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
// 白铁's 铁钳号·原型机 (a registered ally target) is drawn on like an enemy, after every enemy (嘲讽等级 −2) — the
// owner's rule of 2026-10-08; its kit cancels the hits [ASSUMED] (skills.js allyTargetsOk)
allyTargets: true,
onStart({ battle, unit }) {
const list = battle.enemiesInKeys(keysOf(unit, grid2), unit, AIR);
sortEnemyTargets(battle, unit, list, null);
list.push(...battle.allyTargetsInKeys(keysOf(unit, grid2), unit));
const v = list.slice(0, Math.max(1, Math.floor(num(b2.max_target, 1))));
battle.fx('slash', { x: unit.x, y: unit.y, id: unit.id, n: v.length, skill: 'chen:draw' });
const amount = () => unit.s.atk * num(b2.atk_scale, 1);
@@ -132,6 +136,9 @@ export default {
duration: S3_CLIP,
flags: { invulnerable: true, noBlock: true },
attack: { noAttack: true },
// [ASSUMED] its slashes and its guard (guardActivation) take ground enemies only: 白铁's 铁钳号 alone does not open
// it (skills.js allyTargetsOk) — the skill ends at once without a target
allyTargets: false,
onStart({ battle, unit }) {
battle.releaseBlocked(unit); // 无法阻挡
unit.mem.chenS3 = { t: 0, n: 0, target: null };
+23 -2
View File
@@ -46,7 +46,10 @@
// field, the stage tiles beyond it are another player's half] —, an 侵入点 or a 保护目标 it turns 90° clockwise (four
// turns at most per tick; boxed in on every side it waits) and forgets whom it hit; each enemy it can select within 1.3
// (air too) is hit once per straight run for the larger of hp_ratio × its current HP (the unit's own HP, PRTS) and
// projectile_min_atk_scale × her ATK at the hit, arts (RES applies — PRTS "非伤害保底或无视法术抗性"; 普通伤害). Her
// projectile_min_atk_scale × her ATK at the hit, arts (RES applies — PRTS "非伤害保底或无视法术抗性"; 普通伤害).
// The wave is a kit-managed probe, not a snapshot projectile, so it is shown with `fx('chen3Wave')` events every
// WAVE_FX_EVERY s (its position, the previous point and its direction — render/fx 'qi': a procedural blade trail with
// a crescent head, not the official particle prefab); before that the 龙剑气 had no visual at all (PR #382). Her
// begin clips (S1 0.2 s, S3 0.433 s) are not modelled [ASSUMED: the engine starts skills at once].
import { num, talentBb, traitBb, skillRec, statBuff, toggleBuff, up } from '../shared/tier1.js';
@@ -69,6 +72,14 @@ const RETARGET_RADIUS = 1.7;
const WAVE_SPEED = 1.5;
const WAVE_PROBE = 0.25;
const WAVE_HIT_RADIUS = 1.3;
/**
* How often the wave's position is sent to the client as an `fx` (`chen3Wave`, carrying the previous point and its
* direction). The wave is a kit-managed probe (it turns corners and lives as long as the skill), so it is not in the
* snapshot's projectile list: without these events the client has nothing to draw and the 龙剑气 is invisible (the cast
* emitted one `dash` puff and each hit a `slash` spark). The rate is the renderer's: each event draws a short blade
* trail whose life outlasts the gap, so the events join into one continuous wave.
*/
const WAVE_FX_EVERY = 0.12;
/** S1's early end: a silence of hers that ends within this of the skill's planned end ended with it. */
const SILENCE_SLACK = 0.1;
const AIR = Object.freeze({ canHitFly: true });
@@ -215,7 +226,8 @@ export default {
attack: { atkScale: num(b3['attack@atk_scale'], 1), hits: 3 }, // `_additionalTimes` 2
onStart({ battle, unit, skill }) {
const [dr, dc] = unit.fwd;
unit.mem.chen3Wave = { x: unit.x, y: unit.y, dr, dc, hit: new Set(), act: skill.activations, seq: unit.deploySeq };
unit.mem.chen3Wave = { x: unit.x, y: unit.y, dr, dc, hit: new Set(), act: skill.activations, seq: unit.deploySeq,
fx: 0, fxX: unit.x, fxY: unit.y };
battle.fx('dash', { x: unit.x, y: unit.y, id: unit.id, skill: 'chen3:wave' });
},
onEnd({ unit }) { unit.mem.chen3Wave = null; },
@@ -285,8 +297,17 @@ export default {
w.dr = 0 - w.dc; // (`0 - x`: never a negative zero)
w.dc = dr;
w.hit.clear();
w.fxX = w.x; w.fxY = w.y; // the drawn trail starts again at the corner (no streak across the turn)
}
if (!waveBlocked(battle, w)) { w.x += w.dc * WAVE_SPEED * dt; w.y += w.dr * WAVE_SPEED * dt; }
// the 龙剑气 itself: tell the client where it is now (and where it came from) so it can be drawn — while it
// runs, while it is boxed in waiting, and after a turn (the direction comes along for the renderer)
w.fx += dt;
if (w.fx >= WAVE_FX_EVERY) {
w.fx = 0;
battle.fx('chen3Wave', { x: w.x, y: w.y, fromX: w.fxX, fromY: w.fxY, dr: w.dr, dc: w.dc, id: unit.id, skill: 'chen3:wave' });
w.fxX = w.x; w.fxY = w.y;
}
for (const e of battle.foesInRadius(w.x, w.y, WAVE_HIT_RADIUS)) {
if (w.hit.has(e) || !canTargetEnemy(unit, e, AIR)) continue;
w.hit.add(e);
+2 -1
View File
@@ -62,6 +62,7 @@ import { sortEnemyTargets, canTargetEnemy } from '../../../targeting.js';
import { bodyInKeys } from '../../../body.js';
import { hasHp, isHpLoss } from '../../../damage.js';
import { COLS, PROJECTILE_SPEEDS } from '../../../constants.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_cqbw_1';
const S2 = 'skchr_cqbw_2';
@@ -133,7 +134,7 @@ function mineTile(battle, unit) {
let best = [], bd = Infinity;
for (const t of tiles) {
let d = Infinity;
for (const e of foes) d = Math.min(d, Math.hypot(e.x - t[1], e.y - t[0]));
for (const e of foes) d = Math.min(d, hypot(e.x - t[1], e.y - t[0]));
if (d < bd - 1e-9) { bd = d; best = [t]; } else if (Math.abs(d - bd) <= 1e-9) best.push(t);
}
return best.length === 1 ? best[0] : battle.rng.pick(best);
+2 -1
View File
@@ -58,6 +58,7 @@ import { num, talentBb, moduleBb, traitBb, up, toggleBuff, summonTileFree } from
import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
import { bodyOnTile } from '../../../body.js';
import { COLS } from '../../../constants.js';
import { atPotential } from '../../../../../shared/potential.js';
const S1 = 'skchr_doroth_1';
const S2 = 'skchr_doroth_2';
@@ -112,7 +113,7 @@ export default {
/** The token's deploy limit for her loadout (variant stats, the module's when it carries one); her max stock too. */
function limitOf(battle, unit) {
const raw = battle.data.rawToken?.(RESONATOR);
const v = raw?.variants?.[unit.def?.tokenOwner];
const v = atPotential(raw?.variants?.[unit.def?.tokenOwner], unit.def?.loadout?.potential);
const mod = unit.def?.loadout?.moduleId;
const n = num(v?.byModule?.[mod]?.stats?.deployLimit, num(v?.stats?.deployLimit, num(raw?.deployLimit, 0)));
if (n > 0) return n;
+2 -1
View File
@@ -67,6 +67,7 @@ import { num, talentBb, moduleBb, traitBb, skillRec, batMod, up, toggleBuff } fr
import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
import { hasHp } from '../../../damage.js';
import { COLS } from '../../../constants.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_ebnhlz_1';
const S2 = 'skchr_ebnhlz_2';
@@ -116,7 +117,7 @@ function remnantTiles(battle, unit) {
const rank = new Map();
foes.forEach((e, i) => { const k = Math.round(e.y) * COLS + Math.round(e.x); if (!rank.has(k)) rank.set(k, i); });
battle.rng.shuffle(tiles); // the random order of the last group, and of equal distances [ASSUMED]
const dist = (r, c) => { let d = Infinity; for (const e of foes) d = Math.min(d, Math.hypot(e.x - c, e.y - r)); return d; };
const dist = (r, c) => { let d = Infinity; for (const e of foes) d = Math.min(d, hypot(e.x - c, e.y - r)); return d; };
const keyed = tiles.map(([r, c], i) => ({ r, c, i, on: rank.get(r * COLS + c) ?? Infinity, d: dist(r, c) }));
keyed.sort((a, b) => a.on - b.on || a.d - b.d || a.i - b.i);
return keyed.map((x) => [x.r, x.c]);
+2 -1
View File
@@ -50,6 +50,7 @@
import { num, talentBb, moduleBb, moduleOn, skillRec, statBuff, toggleBuff, up } from '../shared/tier1.js';
import { toLocal } from '../../../dir.js';
import { COLS } from '../../../constants.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_haak_1';
const S2 = 'skchr_haak_2';
@@ -80,7 +81,7 @@ export function frontAlly(battle, unit, heal = false) {
if (heal && (a.s.flags.noHeal || a.s.flags.healFree || a.profile?.noHeal)) continue;
const dr = a.tileR - unit.tileR, dc = a.tileC - unit.tileC;
const [lr, lc] = toLocal(dr, dc, unit.dir);
const k = [lr === 0 && lc > 0 ? 0 : 1, Math.hypot(dr, dc), -(a.s.taunt || 0), -(a.aggroSeq || a.deploySeq || 0), a.id];
const k = [lr === 0 && lc > 0 ? 0 : 1, hypot(dr, dc), -(a.s.taunt || 0), -(a.aggroSeq || a.deploySeq || 0), a.id];
let less = !bk;
for (let i = 0; !less && i < k.length; i++) {
if (k[i] === bk[i]) continue;
+6 -3
View File
@@ -50,6 +50,7 @@
import { num, up, traitBb, talentBb, skillRec, batMod, giveSp, protectMods } from '../shared/tier1.js';
import { absoluteRangeKeys } from '../../../targeting.js';
import { COLS } from '../../../constants.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_haruka_1';
const S2 = 'skchr_haruka_2';
@@ -167,9 +168,10 @@ export default {
return {
trait: atkTargets > 1 ? { maxTargets: atkTargets } : null,
skills: {
[S1]: { kind: 'duration', mods: { aspd: num(b1.attack_speed) }, attack: healAttack(healTargets) },
// [ASSUMED] her skills' attacks heal: 白铁's 铁钳号 (禁疗, no patient) alone does not open them (skills.js allyTargetsOk)
[S1]: { kind: 'duration', mods: { aspd: num(b1.attack_speed) }, attack: healAttack(healTargets), allyTargets: false },
[S2]: {
kind: 'duration', attack: healAttack(healTargets + Math.max(0, Math.floor(num(b2['attack@max_target_heal_add'])))),
kind: 'duration', attack: healAttack(healTargets + Math.max(0, Math.floor(num(b2['attack@max_target_heal_add'])))), allyTargets: false,
onStart({ battle, unit }) {
unit.mem.harukaS2Uses = (unit.mem.harukaS2Uses ?? 0) + 1;
unit.mem.harukaS2Endless = unit.mem.harukaS2Uses >= 2;
@@ -184,6 +186,7 @@ export default {
kind: 'duration',
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) },
targeting: { rangeGrid: s3?.rangeGrid ?? null },
allyTargets: false,
attack: healAttack(healTargets),
onStart({ battle, unit }) { rescale(battle, unit); },
onEnd({ battle, unit }) { rescale(battle, unit); }, // (`active` is already false: ×1 again)
@@ -217,7 +220,7 @@ export default {
if (c.source !== unit || c.opts?.regen || !skillOn(unit, S2) || !(c.amount > 0) || !(scale > 0) || !n) return;
const t = c.target;
const foes = battle.foesInRadius(t.x, t.y, r);
foes.sort((x, y) => Math.hypot(x.x - t.x, x.y - t.y) - Math.hypot(y.x - t.x, y.y - t.y) || x.spawnSeq - y.spawnSeq);
foes.sort((x, y) => hypot(x.x - t.x, x.y - t.y) - hypot(y.x - t.x, y.y - t.y) || x.spawnSeq - y.spawnSeq);
const amount = c.amount * scale;
for (const e of foes.slice(0, n)) battle.dealDamage(unit, e, { amount, type: 'arts', isSkill: true, tags: ['skill', 'haruka:s2'] });
if (foes.length) battle.fx('aoe', { x: t.x, y: t.y, radius: r, id: unit.id, skill: 'haruka:s2' });
+6 -2
View File
@@ -57,6 +57,7 @@ import { canTargetEnemy } from '../../../targeting.js';
import { bodyInRadius } from '../../../body.js';
import { hasHp } from '../../../damage.js';
import { COLS } from '../../../constants.js';
import { sin, cos, atan2 } from '../../../detmath.js';
const S1 = 'skchr_hsgma2_1';
const S2 = 'skchr_hsgma2_2';
@@ -98,7 +99,7 @@ export default {
/** S2: the shield's centre after `turned` degrees (row 0 is the bottom row: counter-clockwise = a growing angle). */
const shieldAt = (unit, sh) => {
const a = sh.a0 + (sh.turned * Math.PI) / 180;
return { x: unit.x + SHIELD_START * Math.cos(a), y: unit.y + SHIELD_START * Math.sin(a) };
return { x: unit.x + SHIELD_START * cos(a), y: unit.y + SHIELD_START * sin(a) };
};
const touching = (battle, unit, p) => battle.enemies.filter((e) => canTargetEnemy(unit, e, GROUND) && bodyInRadius(e, p.x, p.y, SHIELD_RADIUS));
/** S2: one shield pulse — 停顿, the arts hit, and her heal of heal_ratio × the HP it removed (ignoring her 禁疗). */
@@ -128,7 +129,7 @@ export default {
if (!('hits' in s2Attack)) return;
plainS2(); // the throw was this attack: the shield flies from now on
const [fr, fc] = unit.fwd;
unit.mem.hsShield = { a0: Math.atan2(fr, fc), turned: 0, acc: 0 };
unit.mem.hsShield = { a0: atan2(fr, fc), turned: 0, acc: 0 };
battle.fx('zone', { x: unit.x, y: unit.y, radius: SHIELD_START + SHIELD_RADIUS, dur: SHIELD_TURN / SHIELD_SPEED, id: unit.id, skill: 'hsgma2:shield' });
shieldPulse(battle, unit, shieldAt(unit, unit.mem.hsShield));
},
@@ -179,6 +180,9 @@ export default {
const capOf = () => num(t0.max_minus_hp_ratio, 2) * unit.s.maxHp;
const quiet = num(t0['hsgma2_t_1[heal].interval'], 5);
const regen = num(t0['hsgma2_t_1[heal].hp_recovery_per_sec_by_max_hp_ratio']);
// the pool as a share of its cap, read when a snapshot is taken (b.snap `neg`, snapshot.js negView): the green HP bar
// sits on the 1-HP floor in 我执, so the client draws the pool as the red bar — display only
unit.negFill = () => (unit.mem.hsEgo && unit.mem.hsEgo.pool > 0 ? unit.mem.hsEgo.pool / Math.max(1e-9, capOf()) : 0);
const enter = (pool) => {
unit.mem.hsEgo = { pool: Math.max(0, pool), lastHurt: battle.time };
battle.addBuff(unit, { key: EGO, status: 'healFree', flags: { noHeal: true, healFree: true }, tags: ['talent'] });
+2 -1
View File
@@ -52,6 +52,7 @@
import { num, talentBb, moduleBb, traitBb, skillRec, up, giveSp } from '../shared/tier1.js';
import { bodyInKeys } from '../../../body.js';
import { hasHp } from '../../../damage.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_ifrit_1';
const S2 = 'skchr_ifrit_2';
@@ -197,7 +198,7 @@ export default {
battle.on('hit', (c) => {
const e = c.target;
if (c.source !== unit || !e || e.side !== 'enemy' || c.dmg.type === 'element' || !up(unit)) return;
const d = Math.hypot(e.x - unit.x, e.y - unit.y);
const d = hypot(e.x - unit.x, e.y - unit.y);
c.dmg.mul *= 1 + ds * (hi > lo ? Math.max(0, Math.min(1, (d - lo) / (hi - lo))) : 1);
}, { owner: unit });
}
+10 -3
View File
@@ -100,6 +100,9 @@ export default {
},
[S2]: {
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
// [ASSUMED] its strike and its guard (guardActivation) take ground enemies only: 白铁's 铁钳号 alone does not open
// it (skills.js allyTargetsOk)
allyTargets: false,
onStart({ battle, unit }) {
const list = battle.enemiesInKeys(keysOf(unit, grid2), unit, GROUND);
sortEnemyTargets(battle, unit, list, null);
@@ -116,9 +119,12 @@ export default {
kind: 'duration',
duration: S3_DURATION,
attack: { noAttack: true },
// 白铁's 铁钳号·原型机 (a registered ally target) is struck like an enemy — the owner's rule of 2026-10-08; its kit
// cancels the hits [ASSUMED: its strikes take it like a ground enemy of the skill range] (skills.js allyTargetsOk)
allyTargets: true,
onStart({ battle, unit }) {
unit.mem.ireneS3 = { t: 0, n: 0 };
const v = battle.enemiesInKeys(keysOf(unit, grid3), unit, GROUND);
const v = [...battle.enemiesInKeys(keysOf(unit, grid3), unit, GROUND), ...battle.allyTargetsInKeys(keysOf(unit, grid3), unit)];
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1.5, id: unit.id, skill: 'irene:judgment' });
for (const e of v) {
battle.dealDamage(unit, e, { amount: unit.s.atk * num(b3.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] });
@@ -132,11 +138,12 @@ export default {
const iv = Math.max(0.05, num(b3.multi_hit_interval, 0.3)), times = Math.max(0, Math.floor(num(b3.multi_times, 10)));
while (m.n < times && m.t + 1e-9 >= (m.n + 1) * iv) {
m.n++;
const cands = battle.enemiesInKeys(keysOf(unit, grid3), unit, AIR);
const foes = battle.enemiesInKeys(keysOf(unit, grid3), unit, AIR);
const cands = foes.length ? foes : battle.allyTargetsInKeys(keysOf(unit, grid3), unit); // (the 铁钳号 when no enemy)
if (!cands.length) continue;
const c = battle.rng.pick(cands);
battle.fx('aoe', { x: c.x, y: c.y, radius: S3_SPLASH, id: unit.id, skill: 'irene:judgment' });
for (const e of battle.foesInRadius(c.x, c.y, S3_SPLASH, true)) {
for (const e of [...battle.foesInRadius(c.x, c.y, S3_SPLASH, true), ...battle.allyTargetsInRadius(c.x, c.y, S3_SPLASH, unit)]) {
battle.dealDamage(unit, e, { amount: unit.s.atk * num(b3.multi_atk_scale, 1), type: 'phys', isSkill: true, isSplash: e !== c, tags: ['skill'] });
}
if (!unit.alive || !unit.skill?.active) return;
+2 -1
View File
@@ -56,7 +56,8 @@
// 白铁's S2 runs: s2.interval s and s2.hp_ratio (both timers restart when the mode changes, as the client re-creates the
// buffs on its mode switch).
// - 铁钳号·原型机 (S3), an enemy-camp summon: our attacks select it like an enemy, after every real one (嘲讽等级 −2;
// Battle.setAllyTarget — the engine extension of this kit), enemies never (untargetable); every damage it takes is cancelled
// Battle.setAllyTarget — the engine extension of this kit), and a non-heal skill's automatic start counts it like an enemy
// (skills.js `_allyTargetIn`, the owner's rule of 2026-10-08), enemies never (untargetable); every damage it takes is cancelled
// and gives it 1 SP (受击回复), and +hp_ratio of its max HP when the source is a 工匠 operator (trait, ignores 禁疗). 团结的力
// 量: the operator on its b-1 (its tile and the one behind it) takes physical damage ×(1 − damage_resistance) — one effect per
// device. Its skill (AUTO, 5 SP, data DEFAULT — it never attacks, so the engine checks it every tick: an enemy, flyers too, in
+3 -2
View File
@@ -58,6 +58,7 @@
import { num, talentBb, traitBb, moduleOn, skillRec, batMod, up, giveSp, installReveal } from '../shared/tier1.js';
import { offsetTile, dirFromDelta, dirVec } from '../../../dir.js';
import { isHpLoss } from '../../../damage.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_jesca2_1';
const S2 = 'skchr_jesca2_2';
@@ -182,7 +183,7 @@ function fireBomb(battle, unit, b3) {
};
(unit.mem.jescaShells ??= []).push(bomb);
unit.atkCd = unit.s.interval; // the shell is her attack: the timer restarts [ASSUMED]
battle.fx('bombardShell', { x: bomb.tx, y: bomb.ty, id: unit.id, r: bomb.radius, t: Math.hypot(bomb.tx - bomb.x, bomb.ty - bomb.y) / BOMB_SPEED });
battle.fx('bombardShell', { x: bomb.tx, y: bomb.ty, id: unit.id, r: bomb.radius, t: hypot(bomb.tx - bomb.x, bomb.ty - bomb.y) / BOMB_SPEED });
}
/** Move the shells one tick; a shell touching an enemy or reaching its end point explodes. */
@@ -192,7 +193,7 @@ function flyShells(battle, unit, dt) {
const keep = [];
for (const s of list) {
const dx = s.tx - s.x, dy = s.ty - s.y;
const d = Math.hypot(dx, dy);
const d = hypot(dx, dy);
const step = BOMB_SPEED * dt;
let hit = battle.foesInRadius(s.x, s.y, BOMB_HIT_RADIUS).length > 0;
if (!hit) {
+2 -1
View File
@@ -76,6 +76,7 @@
import { num, talentBb, skillRec, batMod, up } from '../shared/tier1.js';
import { releaseSkillSummon } from '../../tokens.js';
import { COLS } from '../../../constants.js';
import { hypot } from '../../../detmath.js';
const S1 = 'skchr_kalts2_1';
const S2 = 'skchr_kalts2_2';
@@ -235,7 +236,7 @@ function startTransport(battle, unit, anchor) {
}, { owner: unit });
}
const from = { x: unit.x, y: unit.y }, to = { x: anchor.x, y: anchor.y };
const T = Math.hypot(to.x - from.x, to.y - from.y) / TRANSPORT_SPEED;
const T = hypot(to.x - from.x, to.y - from.y) / TRANSPORT_SPEED;
const f = { t0: battle.time, T, from, to, hit: new Set(), anchor, r: anchor.tileR, c: anchor.tileC, dir: anchor.dir };
unit.mem.k2flight = f;
battle.releaseBlocked(unit);
Loaded 100 of 447 files, more files were not shown because too many files have changed in this diff. Show more