Playtest #5 fixes: hovering enemies, target order, blocking hand-over, 联防 knocked-out, elements, huge-boss hit area, maps, gear icon, phone HUD, 至简 price
- #1 近地悬浮 enemies count as air units (Unit.isFlying): ground-only attacks, splash, terrain and traps skip them; PRTS 不可对空 skill effects made ground-only; 掠海漂移体 / 吉兆飞鳞 drop modes; the bot plans them as air - #2 an operator knocked out before 联防 is fielded and force-exited (PRTS 强制退场): it lies on its tile with the redeploy ring and redeploys (full timer [ASSUMED]); partners, observers and the server fallback included - #3 element audit of every operator, item and enemy: 海嗣 numbers are official; bursts are 无来源, 元素脆弱 only raises 元素伤害, 元素抗性 applied, 盟约·辅助干员 fills all three gauges, 菲莱 / 哈洛德 / 纯烬 / enemy kits fixed - #4 blocking by the official contact radius (0.7071): another operator in contact takes over at once when the blocker dies, withdraws or is stunned; melee blockers attack what they block - #5 enemy target order per PRTS 索敌: blocker, special priority, taunt, last deployed; initial deploy 从上到下>从左到右, operators before summons; special priorities break ties by taunt then deploy order - #6 act2 m01's normal-field blower machines (row 13) are drawn in the normal / 联防 views - #7 Final Assault prep legality reads the player's half of the boss field (fence tiles deployable); all 11 stages audited against the official level files - #8 regular settings gear; #9 the prep camera keeps the bench and temp rows clear of the shop bar on phones - #10 huge bosses have the official 4.95 × 2.95 hit area (sim/body.js) and are 自缚 / unblockable - #11 至简 costs 1: 购买价格为N's bb.price is a discount off the tier price, applied before other price effects - DESIGN §19 and the normative §3 / §5 / §6 / §7 lines, SIM / META / DATA / PLAYING updated; 2595 unit tests and every browser suite green; dev recordings regenerated
This commit is contained in:
1 parent
d4f868cd19
commit
15291c2293
104 files changed
+5539
-662
No files matched your search
+39
-9
@@ -496,9 +496,10 @@ export class Match {
|
||||
ps.eliminate(passedRound);
|
||||
this.tickerText(`${ps.name}博士中途退出了模拟`);
|
||||
if (this.bossWaves && (phase === PHASE.ROUND_START || phase === PHASE.SP_DRAFT || phase === PHASE.PREP)) {
|
||||
// before the boss fight: pair the players left again (the prep preview shows the new partner / template)
|
||||
// before the boss fight: pair the players left again (the prep preview shows the new partner / template); a
|
||||
// player moved to the other half re-checks its board there at once (recompute → deployMap, marks it private)
|
||||
this._planBossWaves();
|
||||
for (const p of this.alivePlayers()) this.markPrivate(p);
|
||||
for (const p of this.alivePlayers()) p.recompute();
|
||||
}
|
||||
this.markPublic();
|
||||
if (this.teamLp != null) this._syncTeamLp();
|
||||
@@ -606,8 +607,35 @@ export class Match {
|
||||
}
|
||||
humans() { return this.order.filter((p) => !p.isBot && !p.left); }
|
||||
|
||||
deployMapFor(ps) {
|
||||
return buildDeployMap(this.stage, { deviceOverrides: ps.deviceOverrides, tileOverrides: ps.tileOverrides });
|
||||
/**
|
||||
* The boss-round group of a player (`bossWaves`: the seat pairs of finalAssault.js pairPlayers, planned in startRound
|
||||
* before the players' round start) and its side — 'L', or 'R' for the second player of a pair (the mirrored right
|
||||
* half) — or null outside a boss round / for a player without a field.
|
||||
* @returns {{ wave: object, players: string[], side: 'L'|'R' } | null}
|
||||
*/
|
||||
bossGroupOf(ps) {
|
||||
if (!ps || !Array.isArray(this.bossWaves)) return null;
|
||||
const g = this.bossWaves.find((x) => Array.isArray(x.players) && x.players.includes(ps.playerId));
|
||||
return g ? { wave: g.wave, players: g.players, side: g.players.indexOf(ps.playerId) === 1 ? 'R' : 'L' } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The field a player deploys on (server/match/board.js DEPLOY_FIELDS): in a boss round (最终攻势 / 隐秘核心, from its
|
||||
* ROUND_START on — the pairing exists before PlayerState.startRound's recompute) the player's half of the boss field,
|
||||
* 'bossL' or 'bossR' (bossGroupOf); otherwise its own normal board. User playtest #5 item 7.
|
||||
* @returns {'normal'|'bossL'|'bossR'}
|
||||
*/
|
||||
deployFieldOf(ps) {
|
||||
const g = this.bossGroupOf(ps);
|
||||
return !g ? 'normal' : g.side === 'R' ? 'bossR' : 'bossL';
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh deploy map of a player on `field` (default: the field it deploys on now) under its device / tile overrides.
|
||||
* Pure — no PlayerState cache is touched (the read-only checker invariants.js uses it; PlayerState.deployMap caches).
|
||||
*/
|
||||
deployMapFor(ps, field = this.deployFieldOf(ps)) {
|
||||
return buildDeployMap(this.stage, { deviceOverrides: ps.deviceOverrides, tileOverrides: ps.tileOverrides, field });
|
||||
}
|
||||
|
||||
dispatch(ps, hook, ev = {}, opts = {}) {
|
||||
@@ -813,10 +841,9 @@ export class Match {
|
||||
nextEnemiesFor(ps) {
|
||||
if (!ps.alive) return [];
|
||||
if (this.bossWaves) {
|
||||
const g = this.bossWaves.find((x) => x.players.includes(ps.playerId));
|
||||
const g = this.bossGroupOf(ps);
|
||||
if (!g) return [];
|
||||
const side = g.players.indexOf(ps.playerId) === 1 ? 'R' : 'L';
|
||||
return previewOf([...g.wave.spawns, ...bountySpawns(this.gd, this.round, g.wave, ps.bounties, ps.playerId, { solo: this.isSolo, side })]);
|
||||
return previewOf([...g.wave.spawns, ...bountySpawns(this.gd, this.round, g.wave, ps.bounties, ps.playerId, { solo: this.isSolo, side: g.side })]);
|
||||
}
|
||||
if (!this.wave) return [];
|
||||
const bounty = bountySpawns(this.gd, this.round, this.wave, ps.bounties, ps.playerId, { solo: this.isSolo });
|
||||
@@ -1305,14 +1332,17 @@ export class Match {
|
||||
this.wave = null;
|
||||
this.bossWaves = null;
|
||||
const alive = this.alivePlayers();
|
||||
for (const ps of alive) ps.startRound(r);
|
||||
// the round's enemies (shared composition, generated now so the prep preview is exact)
|
||||
// the round's enemies (shared composition, generated now so the prep preview is exact). Planned BEFORE the players'
|
||||
// round start: its recompute() checks the board on the field the player deploys on this round (deployFieldOf reads
|
||||
// the boss pairing), so R14 → R15 never re-checks a boss-field board against the normal field (user playtest #5
|
||||
// item 7). rngWaves is used only here, so the order leaves every random stream unchanged.
|
||||
const isBoss = r === this.gd.bossRound || r === this.gd.hiddenRound;
|
||||
if (isBoss) {
|
||||
this._planBossWaves();
|
||||
} else {
|
||||
this.wave = buildNormalWave(this.gd, this.rngWaves, this.factions, r);
|
||||
}
|
||||
for (const ps of alive) ps.startRound(r);
|
||||
for (const ps of alive) this.dispatch(ps, 'onRoundStart', { round: r });
|
||||
for (const ps of alive) ps.recompute();
|
||||
this.setDeadline(DELAYS.ROUND_START / 1000, () => this.afterRoundStart(), { silent: this.soloUntimed });
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
// usable (move to a free hand slot, place, equip, destroy, sell) through the NEXT prep — nothing is destroyed before
|
||||
// the player saw it in a prep ("处于临时整备区的调度资源,在进入下一回合后会自动销毁").
|
||||
// * Board: own region rows 9–12 × cols 2–10, legality from the stage legend (board.js); deploy cap 8 (+effects);
|
||||
// tokens (placeable summons) don't use deploy slots. Board↔hand swaps are always allowed. A terrain change
|
||||
// (terrain 机变 cards, content overrides) withdraws the pieces left on tiles they may no longer occupy (hand,
|
||||
// tokens (placeable summons) don't use deploy slots. Board↔hand swaps are always allowed. In a boss round the
|
||||
// legality reads the player's half of the boss field (Match.deployFieldOf → board.js field 'bossL' / 'bossR',
|
||||
// user playtest #5 item 7); board coordinates are unchanged. A terrain change (terrain 机变 cards, content
|
||||
// overrides) or a change of the deploy field withdraws the pieces left on tiles they may no longer occupy (hand,
|
||||
// overflow temp; summons back onto their stack) — see _evictIllegal.
|
||||
// * Shop: per-level chess slots + item slot, copy-weighted rolls from the SHARED pool (pool.js); refresh 1 (free
|
||||
// refreshes first), one toggle freezes all unsold slots until the next round start (a manual refresh while frozen
|
||||
@@ -122,6 +124,8 @@ export class PlayerState {
|
||||
/** last combat result for this player (unite carry state, bounties) */
|
||||
this.lastResult = null;
|
||||
this._deployMap = null;
|
||||
/** the deploy field of `_deployMap` ('normal' | 'bossL' | 'bossR', Match.deployFieldOf) */
|
||||
this._deployField = undefined;
|
||||
/** the deploy map changed since the board's legality was last checked (invalidateDeployMap) */
|
||||
this._legalityStale = false;
|
||||
/** Match.scheduleBotPrep: the latest bot prep of this seat (older sliced rehearsals drop out) */
|
||||
@@ -199,8 +203,19 @@ export class PlayerState {
|
||||
return resolveLoadout(this.loadout, chessRecord, (id) => this.gd.chess(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy classes of the board tiles (server/match/board.js buildDeployMap) on the field the player deploys on now
|
||||
* (Match.deployFieldOf: the own board, or its half of the boss field in a boss round — user playtest #5 item 7).
|
||||
* A change of that field (the boss round begins, a re-pairing) re-checks the board's legality like a terrain change.
|
||||
*/
|
||||
deployMap() {
|
||||
if (!this._deployMap) this._deployMap = this.m.deployMapFor(this);
|
||||
const field = typeof this.m.deployFieldOf === 'function' ? this.m.deployFieldOf(this) : 'normal';
|
||||
if (this._deployMap && this._deployField !== undefined && this._deployField !== field) {
|
||||
this._deployMap = null;
|
||||
this._legalityStale = true;
|
||||
}
|
||||
if (!this._deployMap) this._deployMap = this.m.deployMapFor(this, field);
|
||||
this._deployField = field;
|
||||
return this._deployMap;
|
||||
}
|
||||
/**
|
||||
@@ -1280,6 +1295,7 @@ export class PlayerState {
|
||||
}
|
||||
|
||||
recompute() {
|
||||
this.deployMap(); // a change of the deploy field (a boss round's prep) marks the legality stale
|
||||
if (this._legalityStale) this._evictIllegal();
|
||||
this.bonds = computeBonds(this.gd, this);
|
||||
this.dirty();
|
||||
@@ -1292,6 +1308,7 @@ export class PlayerState {
|
||||
|
||||
battleInput({ side = 'L', colOffset = 0, carry = null } = {}) {
|
||||
// a terrain change not yet followed by a recompute (a content hook at the prep end) never fields an illegal board
|
||||
this.deployMap();
|
||||
if (this._legalityStale) this.recompute();
|
||||
const units = [];
|
||||
for (const { r, c, piece } of boardOrder(this.board)) {
|
||||
|
||||
+43
-6
@@ -10,12 +10,22 @@
|
||||
// `tileOverrides { 'r,c': 'melee'|'ranged'|'none' }` force a class. The result equals stages[id].deployTiles.normal
|
||||
// for the unmodified stage (asserted in test/match/board.test.js).
|
||||
//
|
||||
// Deploy field (user playtest #5 item 7): in the prep of a boss round (最终攻势 / 隐秘核心) the player deploys on ITS
|
||||
// half of the boss field (research 09 §1.2, official ConvertChessPositionInfoToBossMap / player_map_ud_offset 7 /
|
||||
// player_map_lr_boundary_col 10), so legality reads the tiles and devices THERE: `field` 'bossL' → board (r, c) is
|
||||
// stage tile (r − 7, c); 'bossR' (the second player of a pair, mirrored) → (r − 7, 20 − c); 'normal' → (r, c). Board
|
||||
// coordinates stay the same everywhere (the server's board, g.move, the sim input); only the tile read changes. E.g.
|
||||
// act2 m01's 1×3 fenced tiles (tile_fence_bound, LOW / buildable ALL) at boss (3–5, 8) and (3–5, 12) = board
|
||||
// (10–12, 8), which are '#' on the normal field. The result equals stages[id].deployTiles.bossLeft / bossRight
|
||||
// mapped to board coordinates (test/match/playtest5-deploy.test.js).
|
||||
//
|
||||
// Tokens follow their own `position` (ALL ⇒ any deployable tile, MELEE ⇒ melee tiles, RANGED ⇒ any deployable).
|
||||
// Facing: board pieces carry `dir` ∈ UP|RIGHT|DOWN|LEFT (server/sim/dir.js); `pieceDir` reads it (absent ⇒ RIGHT),
|
||||
// `parseDir` validates an intent's optional direction.
|
||||
|
||||
import { GEO } from '../../shared/constants.js';
|
||||
import { DEFAULT_DIR, isDir } from '../sim/dir.js';
|
||||
import { BOSS_ROW_OFFSET, COLS } from '../sim/constants.js';
|
||||
|
||||
export const FIELD = GEO.FIELD; // { r0: 9, r1: 12, c0: 2, c1: 10 }
|
||||
export const tileKey = (r, c) => `${r},${c}`;
|
||||
@@ -29,22 +39,49 @@ export const inField = (r, c) => Number.isInteger(r) && Number.isInteger(c) && r
|
||||
const OBSTACLE_ROLES = new Set(['crate', 'mound']);
|
||||
const PLATFORM_ROLES = new Set(['platform']);
|
||||
|
||||
/** Deploy fields: the own normal board, or the player's half of the boss field (left / mirrored right). */
|
||||
export const DEPLOY_FIELDS = Object.freeze(['normal', 'bossL', 'bossR']);
|
||||
/**
|
||||
* How many rows a board row lies below its boss-field row: board row = boss-field row + 7 (official
|
||||
* player_map_ud_offset 7; = −sim BOSS_ROW_OFFSET). Note the sign: render/prepfield.js BOSS_ROW_SHIFT is −7 (board →
|
||||
* display row), the opposite direction.
|
||||
*/
|
||||
export const BOARD_ROWS_ABOVE_BOSS = -BOSS_ROW_OFFSET;
|
||||
/** Mirror column of the right boss half: board col c ↔ field col 20 − c (sim Battle.mapTile). */
|
||||
export const BOSS_MIRROR_COL = COLS - 1;
|
||||
const fieldOf = (f) => (DEPLOY_FIELDS.includes(f) ? f : 'normal');
|
||||
|
||||
/** The stage tile [row, col] a board tile (r, c) stands on in deploy field `field`. */
|
||||
export function fieldTile(field, r, c) {
|
||||
const f = fieldOf(field);
|
||||
if (f === 'normal') return [r, c];
|
||||
return [r - BOARD_ROWS_ABOVE_BOSS, f === 'bossR' ? BOSS_MIRROR_COL - c : c];
|
||||
}
|
||||
|
||||
/** Inverse of fieldTile: the board tile of stage tile (r, c) in deploy field `field`. */
|
||||
export function boardTileOf(field, r, c) {
|
||||
const f = fieldOf(field);
|
||||
if (f === 'normal') return [r, c];
|
||||
return [r + BOARD_ROWS_ABOVE_BOSS, f === 'bossR' ? BOSS_MIRROR_COL - c : c];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object|null} stage data/stages.json entry
|
||||
* @param {{ deviceOverrides?: Record<string, boolean>, tileOverrides?: Record<string, string> }} [opts]
|
||||
* @returns {Map<string, 'melee'|'ranged'>}
|
||||
* @param {{ deviceOverrides?: Record<string, boolean>, tileOverrides?: Record<string, string>, field?: 'normal'|'bossL'|'bossR' }} [opts]
|
||||
* @returns {Map<string, 'melee'|'ranged'>} keyed by BOARD tile 'r,c' (rows 9–12, cols 2–10)
|
||||
*/
|
||||
export function buildDeployMap(stage, { deviceOverrides = {}, tileOverrides = {} } = {}) {
|
||||
export function buildDeployMap(stage, { deviceOverrides = {}, tileOverrides = {}, field = 'normal' } = {}) {
|
||||
/** @type {Map<string, 'melee'|'ranged'>} */
|
||||
const map = new Map();
|
||||
const rows = stage && Array.isArray(stage.rows) ? stage.rows : null;
|
||||
const legend = stage && stage.tiles && typeof stage.tiles === 'object' ? stage.tiles : {};
|
||||
for (let r = FIELD.r0; r <= FIELD.r1; r++) {
|
||||
const line = rows && typeof rows[r] === 'string' ? rows[r] : null;
|
||||
for (let c = FIELD.c0; c <= FIELD.c1; c++) {
|
||||
const [sr, sc] = fieldTile(field, r, c);
|
||||
const line = rows && typeof rows[sr] === 'string' ? rows[sr] : null;
|
||||
let cls = null;
|
||||
if (line) {
|
||||
const g = line[c];
|
||||
const g = line[sc];
|
||||
const t = g != null && Object.hasOwn(legend, g) ? legend[g] : null;
|
||||
if (t) {
|
||||
const b = t.buildable;
|
||||
@@ -61,7 +98,7 @@ export function buildDeployMap(stage, { deviceOverrides = {}, tileOverrides = {}
|
||||
const devices = stage && Array.isArray(stage.devices) ? stage.devices : [];
|
||||
for (const d of devices) {
|
||||
if (!d || !Array.isArray(d.pos)) continue;
|
||||
const [r, c] = d.pos;
|
||||
const [r, c] = boardTileOf(field, d.pos[0], d.pos[1]);
|
||||
if (!inField(r, c)) continue;
|
||||
let active;
|
||||
if (d.alias != null && deviceOverrides && Object.hasOwn(deviceOverrides, d.alias)) active = !!deviceOverrides[d.alias];
|
||||
|
||||
+20
-18
@@ -16,9 +16,10 @@
|
||||
// 3. lineup: the deployed set maximizes unit value + activated bond tiers (exact counting via computeBonds) +
|
||||
// composition (chooseLineup: greedy seed + swap hill-climbing).
|
||||
// 4. placement (planLayout): the round's routes are traced over the own board from the enemy preview (ground
|
||||
// routes on the stage's device-aware ground paths, flying routes through their checkpoints) and weighted by
|
||||
// their enemies; an exposure model (tile time × DPS of the covering units, blocker hold time, flyers only for
|
||||
// anti-air) is maximized greedily — blockers first, then damage dealers by DPS, then healers — over every
|
||||
// routes on the stage's device-aware ground paths, flying routes through their checkpoints; 近地悬浮 enemies walk
|
||||
// the ground path but count as flyers) and weighted by their enemies; an exposure model (tile time × DPS of the
|
||||
// covering units, blocker hold time, flyers only for anti-air) is maximized greedily — blockers first, then damage
|
||||
// dealers by DPS, then healers — over every
|
||||
// (legal tile, direction) pair: each unit's range grid 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
|
||||
@@ -37,11 +38,12 @@
|
||||
|
||||
import { GEO } from '../../shared/constants.js';
|
||||
import { deriveSeed } from '../sim/rng.js';
|
||||
import { freeSlot, legalTiles, positionClass, parseKey, tileKey, FIELD, pieceDir } from './board.js';
|
||||
import { freeSlot, legalTiles, positionClass, parseKey, tileKey, FIELD, pieceDir, boardTileOf, BOSS_MIRROR_COL } from './board.js';
|
||||
import { rotateOffset, normDir, mirrorDir, oppositeDir } from '../sim/dir.js';
|
||||
import { itemKey } from './gamedata.js';
|
||||
import { computeBonds } from './bondsMeta.js';
|
||||
import { withBounties } from './waves.js';
|
||||
import { HOVER_KEYS } from '../sim/content/enemies.js';
|
||||
|
||||
/**
|
||||
* Drive a step generator (planLayoutSteps, createRehearsalSteps, arrangeSteps, botPrepBeginSteps …) to its end in one
|
||||
@@ -101,6 +103,8 @@ export function botPickCard(m, ps, cards, available) {
|
||||
const chessRec = (m, id) => m.gd.chess(id);
|
||||
const isHealer = (c) => !!c && (c.dmgType === 'heal' || c.attackKind === 'heal');
|
||||
const isBlocker = (c) => !!c && positionClass(c) === 'melee' && (c.stats?.blockCnt ?? 1) > 0 && c.attackKind !== 'none';
|
||||
/** 近地悬浮 enemies walk a ground route but are air units (no block, anti-air only — DESIGN §19). */
|
||||
const HOVER = new Set(HOVER_KEYS);
|
||||
const hitsFly = (c) => !!c && !!c.canHitFly && !isHealer(c) && c.attackKind !== 'none';
|
||||
|
||||
/** Distinct owned members per bond (board + hand + temp), and per-bond member sets. */
|
||||
@@ -330,9 +334,7 @@ function traceLine(points) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Boss field → own board: board rows 9–12 are boss rows 2–5 (sim BOSS_ROW_OFFSET −7); the right side is mirrored. */
|
||||
const BOSS_ROW_SHIFT = 7;
|
||||
const BOSS_MIRROR_COL = 20;
|
||||
/** Boss field → own board: board.js boardTileOf (board rows 9–12 are boss rows 2–5; the right side mirrored). */
|
||||
const BOSS_MID_COL = 10;
|
||||
/** Dwell (s) the planner assumes on a leader's first own tiles (several leaders fight from their spawn point). */
|
||||
const BOSS_DWELL = 30;
|
||||
@@ -340,17 +342,16 @@ const BOSS_DWELL = 30;
|
||||
const LEADER_WEIGHT = 10;
|
||||
const LEADER_HP = 40000;
|
||||
|
||||
/** The boss-round wave of a player's group and its side ('L' | 'R'), or null. */
|
||||
/** The boss-round wave of a player's group and its side ('L' | 'R'), or null (Match.bossGroupOf). */
|
||||
function bossWaveOf(m, ps) {
|
||||
if (!ps || !Array.isArray(m.bossWaves)) return null;
|
||||
const g = m.bossWaves.find((x) => Array.isArray(x.players) && x.players.includes(ps.playerId));
|
||||
if (!g || !g.wave) return null;
|
||||
return { wave: g.wave, side: g.players.indexOf(ps.playerId) === 1 ? 'R' : 'L' };
|
||||
const g = typeof m.bossGroupOf === 'function' ? m.bossGroupOf(ps) : null;
|
||||
return g && g.wave ? { wave: g.wave, side: g.side } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The round's routes over the own board (cached per round): [{ n, fly, tiles: ['r,c'…] (gate → objective, own
|
||||
* region only, objective tile excluded), tileTime (s per tile), hp }], a tile → [[route, index]] index, and the
|
||||
* The round's routes over the own board (cached per round): [{ n, fly (air units: flyers, and the 近地悬浮 HOVER_KEYS
|
||||
* on their ground path), tiles: ['r,c'…] (gate → objective, own region only, objective tile excluded), tileTime (s per
|
||||
* tile), hp }], a tile → [[route, index]] index, and the
|
||||
* aggregated ground flow per tile. Built from the wave preview (spawn counts per route, enemy HP / speed with the
|
||||
* round's multipliers). Boss rounds (pass `ps`): the player's boss-field template mapped onto the own board (rows −7,
|
||||
* the right player's half mirrored; only the routes that end on the player's half), the leader weighted heavily with
|
||||
@@ -367,7 +368,7 @@ export function fieldModel(m, ps = null) {
|
||||
const gpaths = (st && (st.groundPathsWithDevices || st.groundPaths)) || {};
|
||||
const routesOut = [];
|
||||
const toBoard = boss
|
||||
? ([r, c]) => [r + BOSS_ROW_SHIFT, boss.side === 'R' ? BOSS_MIRROR_COL - c : c]
|
||||
? ([r, c]) => boardTileOf(boss.side === 'R' ? 'bossR' : 'bossL', r, c)
|
||||
: (p) => p;
|
||||
const pushRoute = (tilesRC, n, fly, hp, speed, dwell = 0) => {
|
||||
const own = tilesRC.map(toBoard).filter(([r, c]) => inRect(r, c)).map(([r, c]) => tileKey(r, c));
|
||||
@@ -400,8 +401,9 @@ export function fieldModel(m, ps = null) {
|
||||
// the leader counts like LEADER_WEIGHT tough enemies: its huge pool makes damage on it worth a lot everywhere
|
||||
const hp = isLeader ? LEADER_HP : ((e && e.stats && e.stats.maxHp) || 1000) * ((s.mods && s.mods.hpMul) || 1);
|
||||
const spd = ((e && e.stats && e.stats.moveSpeed) || 1) * ((s.mods && s.mods.speedMul) || 1);
|
||||
const key = `${s.routeIndex}|${isLeader ? 'boss' : ''}`;
|
||||
const a = per.get(key) || { ri: s.routeIndex, boss: isLeader, n: 0, hp: 0, spd: 0 };
|
||||
const air = HOVER.has(s.enemyKey);
|
||||
const key = `${s.routeIndex}|${isLeader ? 'boss' : ''}|${air ? 'air' : ''}`;
|
||||
const a = per.get(key) || { ri: s.routeIndex, boss: isLeader, air, n: 0, hp: 0, spd: 0 };
|
||||
a.n += isLeader ? LEADER_WEIGHT : n; a.hp += hp * n; a.spd += spd * n;
|
||||
per.set(key, a);
|
||||
}
|
||||
@@ -418,7 +420,7 @@ export function fieldModel(m, ps = null) {
|
||||
} else {
|
||||
const key = `${rt.start[0]},${rt.start[1]}->${rt.end[0]},${rt.end[1]}`;
|
||||
const path = Array.isArray(gpaths[key]) ? gpaths[key] : traceLine([rt.start, ...(boss && Array.isArray(rt.checkpoints) ? rt.checkpoints : []), rt.end]);
|
||||
pushRoute(path, a.n, false, a.hp / cnt, a.spd / cnt, dwell);
|
||||
pushRoute(path, a.n, a.air, a.hp / cnt, a.spd / cnt, dwell); // a hovering enemy: ground tiles, air unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,9 @@
|
||||
// ctx.triggerGarrisons(uid, eventType) re-runs another piece's garrisons (铃兰, "触发…的获得时效果", 特质相同).
|
||||
// Items: onEquip / onArt / onDestroy go to the item's own handler only; every other hook runs for items equipped on
|
||||
// owned chess (ev.source.holder). Dispatch order per player: global → band → bonds → garrisons (board reading order,
|
||||
// then hand) → equipped items → effects (insertion order). Every call is try/catch-guarded; nested dispatch depth
|
||||
// is capped (MAX_DEPTH) so content can never loop the server.
|
||||
// then hand) → equipped items → effects (insertion order); onPrice runs the priced chess's own 特质 first (购买价格为N
|
||||
// sets the price the discounts and caps of bonds / strategies then act on — user playtest #5). Every call is
|
||||
// try/catch-guarded; nested dispatch depth is capped (MAX_DEPTH) so content can never loop the server.
|
||||
|
||||
import { itemKey } from './gamedata.js';
|
||||
import { boardOrder, parseKey, tileKey } from './board.js';
|
||||
@@ -188,6 +189,8 @@ export class EffectDispatcher {
|
||||
this.depth++;
|
||||
try {
|
||||
const reg = this.registry;
|
||||
// 0. onPrice: the priced chess's own 特质 first — 购买价格为N defines the price every other modifier acts on
|
||||
if (hook === 'onPrice') this._garrisons(ps, hook, ev);
|
||||
// 1. globals
|
||||
for (const [key, h] of reg.globals()) this._call(ps, key, h, hook, { kind: 'global', key }, ev);
|
||||
// 2. band
|
||||
@@ -202,8 +205,8 @@ export class EffectDispatcher {
|
||||
const h = reg.get(key);
|
||||
if (h) this._call(ps, key, h, hook, { kind: 'bond', key, bondId, bond: ps.bonds[bondId] ?? null }, ev);
|
||||
}
|
||||
// 4. garrisons
|
||||
this._garrisons(ps, hook, ev);
|
||||
// 4. garrisons (onPrice: already run as step 0)
|
||||
if (hook !== 'onPrice') this._garrisons(ps, hook, ev);
|
||||
// 5. equipped items (not for the item-specific hooks)
|
||||
if (hook !== 'onEquip' && hook !== 'onArt' && hook !== 'onDestroy') {
|
||||
for (const holder of ownedChess(ps)) {
|
||||
|
||||
@@ -96,7 +96,9 @@ export function collectViolations(m, { limit = 25 } = {}) {
|
||||
}
|
||||
// board
|
||||
let deployed = 0;
|
||||
const dmap = ps.deployMap();
|
||||
// a pure read (Match.deployMapFor: the field the player deploys on now) — PlayerState.deployMap() would update the
|
||||
// player's cached field / legality flag, and this checker must not change when a later recompute moves pieces
|
||||
const dmap = typeof m.deployMapFor === 'function' ? m.deployMapFor(ps) : ps.deployMap();
|
||||
for (const [k, p] of ps.board) {
|
||||
const [r, c] = parseKey(k);
|
||||
if (!(r >= FIELD.r0 && r <= FIELD.r1 && c >= FIELD.c0 && c <= FIELD.c1)) fail(`${id}: piece outside the board at ${k}`);
|
||||
|
||||
+13
-11
@@ -7,12 +7,17 @@
|
||||
// units "率先迎敌" on the RIGHT-hand field (colOffset +8, where the escaped_multi routes enter), the other keeps the
|
||||
// left half (colOffset 0); a lone helper plays escaped_single on its own field. Their operators keep HP%, SP and a
|
||||
// running timed skill from the end of their own combat (BattleResult.unitsEnd → PlayerBattleInput.units[].carryState,
|
||||
// "阵地以其当前状态"); an operator dead at the end of its own combat (HP ratio 0) and its summons stay out of the 联防
|
||||
// battle [ASSUMED: the sim has no "start undeployed with a running redeploy timer" input, so it does not come back
|
||||
// mid-联防 either]. Enemies = the union of every leaker's counted leaks (same stats: the SpawnSpec mods travel with
|
||||
// the leak), routed on the escaped template (`escaped_single` for 1 helper, `escaped_multi` for 2): walkers on its
|
||||
// `lrsldr` action, flyers on `yokai`, tokens on `gopro_2` / `lazerd` (waves.js buildUniteWave); kill bounties keep
|
||||
// paying the killer (a helper). No IN_BATTLE layer gains. Time limit = the round's combat limit.
|
||||
// "阵地以其当前状态"). An operator knocked out at the end of its own combat (alive false) is fielded with
|
||||
// `carryState: { down: true }`: PRTS "部署完成后,将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力,
|
||||
// 上一阶段为退场状态的干员强制退场)" — the sim deploys it with everyone and forces it out at once (constants.js
|
||||
// FORCED_EXIT), so it lies on its own tile with the redeploy ring and comes back like after any knock-out (user
|
||||
// playtest #5 item 2: it used to be left out and vanished). Its timer is its full redeploy time [ASSUMED: the
|
||||
// official 联防 setup carries only hp / tech per operator (research 09 §3 HelpBattleInfo), no timer]. Summons are fielded as the board has
|
||||
// them (a summon's own end state is not carried: unitsEnd lists operators only). Enemies = the union of every leaker's
|
||||
// counted leaks (same stats: the SpawnSpec mods travel with the leak), routed on the escaped template (`escaped_single`
|
||||
// for 1 helper, `escaped_multi` for 2): walkers on its `lrsldr` action, flyers on `yokai`, tokens on `gopro_2` /
|
||||
// `lazerd` (waves.js buildUniteWave); kill bounties keep paying the killer (a helper). No IN_BATTLE layer gains. Time
|
||||
// limit = the round's combat limit.
|
||||
// LP: an enemy still alive at the end (leaked in the unite battle, or never spawned before the limit) costs its
|
||||
// SOURCE player 1 LP; each player's round loss = min(lpCap, survivors attributed to them + leaks that could not
|
||||
// re-enter) — the same 10 cap as a normal round.
|
||||
@@ -99,19 +104,16 @@ export function uniteBattleOpts(m, plan, timeLimit) {
|
||||
const wave = buildUniteWave(m.gd, plan.leaked, plan.helpers.length, timeLimit);
|
||||
const players = plan.helpers.map((ps, i) => {
|
||||
const carry = new Map();
|
||||
const dead = new Set();
|
||||
const r = m.lastResults.get(ps.playerId);
|
||||
for (const u of (r && r.unitsEnd) || []) {
|
||||
if (!u || u.uid == null) continue;
|
||||
if (!u.alive) { dead.add(u.uid); continue; }
|
||||
// knocked out at the end of its own combat: 强制退场 right after the deployment (see header)
|
||||
if (!u.alive) { carry.set(u.uid, { down: true }); continue; }
|
||||
carry.set(u.uid, { hpPct: Number.isFinite(u.hpPct) ? Math.max(0.01, Math.min(1, u.hpPct)) : 1, sp: Number.isFinite(u.sp) ? u.sp : 0, skillActive: !!u.skillActive });
|
||||
}
|
||||
// 2 helpers: the first one meets the enemies first on the right-hand field (escaped_multi enters at col 18)
|
||||
const colOffset = plan.helpers.length > 1 && i === 0 ? 8 : 0;
|
||||
const input = ps.battleInput({ side: 'L', colOffset, carry });
|
||||
// "阵地以其当前状态": an operator knocked out in the helper's own combat is not on that position (HP ratio 0) — it
|
||||
// stays out of the 联防 battle together with its summons instead of redeploying at full HP
|
||||
if (dead.size) input.units = input.units.filter((u) => !(u.kind === 'token' ? dead.has(u.ownerUid) : dead.has(u.uid)));
|
||||
const ev = { input, kind: 'unite', round: m.round, spawns: wave.spawns };
|
||||
m.dispatch(ps, 'onBattleStart', ev);
|
||||
return ev.input && typeof ev.input === 'object' ? ev.input : input;
|
||||
|
||||
Reference in new issue
Block a user