Files
ark-stronghold-protocol/server/sim/battle/players.js
T
sganggs 9b164fdc66 feedback5 CB3: 乌尔比安's S3 moves only onto a tile of his own board — no longer behind the red gate of the one-helper 联防 map nor onto a boss field's other half (community report of 2026-10-06, item 27)
Official: PRTS 乌尔比安 S3 备注 ③ — the 【移动】 takes the anchor tile if it can be deployed on, else the tile one beyond, else
his own tile. Cause: "deployable" was any standable tile of the battle rect; UNITE_RECT holds both halves of the 联防 map, and
on escaped_single (the lone helper on the left half, the enemies out of the middle gate at col 10) an anchor that met no
enemy flew on to the right half, where no enemy walks, and he stood there for the whole skill. Now the landing must also
lie on his player's board: Battle.onOwnBoard(ps, r, c), the board region GEO.FIELD mapped onto the field (the hunk is
byte-identical to CB4's 219ab31, which uses it for the 突袭 landing, so the two merge cleanly).

Golden: 1 scenario — fields hidden-boss_9-solo: his first cast from (2,5) landed on (2,11), the solo boss field's right
half (checked instrumented); he now stays and the fight differs from there. Tests: test/sim/feedback5-ulpia-own-board.test.js
(4; the one-helper and the two-helper 联防 cases fail on the parent); test/sim/facing-invariance.test.js — its open field
has no boards, so its setup counts every tile as the player's own (the own-board rule is a board-position rule, like the
deployment order the scenario already avoids).
2026-10-07 02:30:07 +08:00

210 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server/sim/battle/players.js — Battle methods: players and units: board → field tiles and directions, ally creation
// from the PlayerBattleInput, token defs for an owner's loadout, and the kit / profile / skill setup of a unit.
// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle).
import { COLS, BOSS_ROW_OFFSET } from '../constants.js';
import { GEO } from '../../../shared/constants.js';
import { Unit } from '../units.js';
import { normDir, mirrorDir } from '../dir.js';
import { SkillRuntime } from '../skills.js';
import { resolveProfile } from '../professions.js';
import { normalizeToken } from '../simdata.js';
import { setupUnitKit } from '../content/index.js';
import { clone } from './util.js';
export class BattlePlayers {
_addPlayer(p) {
const bossLike = this.kind === 'boss' || this.kind === 'hidden';
const side = p.side === 'R' ? 'R' : 'L';
const mirror = bossLike && side === 'R';
const ps = {
playerId: p.playerId,
seat: p.seat ?? this.players.length,
side,
colOffset: Number(p.colOffset) || 0,
rowOffset: p.rowOffset,
coords: p.coords ?? 'board',
mirror,
facing: mirror ? -1 : 1,
// default direction of this player's units on this field (the FA right side is mirrored: RIGHT ↔ LEFT)
dir: mirror ? 'LEFT' : 'RIGHT',
half: mirror || (Number(p.colOffset) || 0) >= 8 ? 'R' : 'L',
bonds: clone(p.bonds ?? {}),
bandId: p.bandId ?? null,
playerEffects: clone(p.playerEffects ?? []),
lpForBoss: p.lpForBoss ?? null,
dp: this.flags.dpInit,
units: [],
input: p,
};
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: [],
};
const late = [];
for (const u of p.units ?? []) {
try {
const unit = this._createAllyFromInput(ps, u);
if (unit && unit.kind === 'token' && !unit.ownerUnit && u.ownerUid != null) late.push([unit, u]);
} catch (e) { this._internalError('createUnit', e); }
}
// a token piece listed before its owner (board order is top→bottom) is linked once every unit exists, and takes
// the owner-level variant (elite owners: `_b` stats) of the owner's loadout (DESIGN §16)
for (const [t, inp] of late) {
const owner = ps.units.find((x) => x.kind === 'op' && x.uid === inp.ownerUid);
if (!owner) continue;
t.ownerUnit = owner;
const def = inp.def ? null : this._tokenDef(inp.tokenId ?? inp.chessId, owner, null);
if (!def || def === t.def) continue;
const st = def.stats;
t.def = def; t.defId = def.id; t.name = def.name; t.rangeGrid = def.rangeGrid;
Object.assign(t.base, {
maxHp: st.maxHp, atk: st.atk, def: st.def, res: st.res, aspd: st.aspd, bat: st.bat, blockCnt: st.blockCnt, spRecovery: st.spRecovery,
tauntLevel: st.tauntLevel, massLevel: st.massLevel, hpRecoveryPerSec: st.hpRecoveryPerSec, cost: st.cost, respawnTime: st.respawnTime,
});
t.markDirty();
t.hp = t.s.maxHp;
}
}
/** Board → field tile for a player (see header). */
mapTile(ps, row, col, abs = false) {
if (abs || ps.coords === 'field') return [row, col];
return this._boardTile(ps, row, col);
}
/**
* Whether field tile (r, c) lies on player `ps`'s own board: the board region GEO.FIELD (rows 9–12, cols 2–10, the
* tiles its pieces are deployed on) mapped onto this field — the 联防 right-hand helper's +8 columns, the boss field's
* rows 2–5, the mirrored right half of a pair (whatever coordinates the player's input uses). Never the hand / 临时
* 整备区 rows of a boss field (rows 0–1, inside BOSS_RECT) nor the other half of a 联防 or boss field. Automatic
* redeployments that must stay where the player could deploy read it (the 突袭 landing, bonds/addon/battle.js raidTile).
*/
onOwnBoard(ps, r, c) {
if (!ps) return false;
let keys = ps._boardKeys;
if (!keys) {
keys = ps._boardKeys = new Set();
for (let br = GEO.FIELD.r0; br <= GEO.FIELD.r1; br++) {
for (let bc = GEO.FIELD.c0; bc <= GEO.FIELD.c1; bc++) { const [fr, fc] = this._boardTile(ps, br, bc); keys.add(fr * COLS + fc); }
}
}
return keys.has(r * COLS + c);
}
_boardTile(ps, row, col) {
const bossLike = this.kind === 'boss' || this.kind === 'hidden';
let r = row;
if (ps.rowOffset != null) r = row + ps.rowOffset;
else if (bossLike && row >= 7) r = row + BOSS_ROW_OFFSET;
const c = ps.mirror ? (col <= 10 ? COLS - 1 - col : col) : col + ps.colOffset;
return [r, c];
}
/**
* Board direction → field direction for a player (DESIGN §3): as given (default RIGHT) except on the mirrored Final
* Assault right side, where RIGHT ↔ LEFT (UP / DOWN unchanged). `abs` / field coordinates are taken as they are.
*/
mapDir(ps, dir, abs = false) {
const d = normDir(dir);
return ps && ps.mirror && !abs && ps.coords !== 'field' ? mirrorDir(d) : d;
}
_createAllyFromInput(ps, inp) {
// coerce (a string row would otherwise concatenate: "10" + -7 → "10-7"); non-integers never deploy
const row = Number(inp.row), col = Number(inp.col);
if (!Number.isInteger(row) || !Number.isInteger(col)) { this.log(`bad tile ${inp.row},${inp.col} for ${inp.chessId ?? inp.tokenId}`); return null; }
const [r, c] = this.mapTile(ps, row, col, !!inp.abs);
const dir = this.mapDir(ps, inp.dir, !!inp.abs);
if (inp.kind === 'token') {
const owner = inp.ownerUid != null ? ps.units.find((x) => x.uid === inp.ownerUid) : null;
const def = this._tokenDef(inp.tokenId ?? inp.chessId, owner, inp.def);
if (!def) { this.log(`unknown token ${inp.tokenId}`); return null; }
const u = this._makeAlly(ps, def, 'token', r, c, { uid: inp.uid, ownerUnit: owner, dir });
u.carry = inp.carryState ?? null;
return u;
}
// 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)
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;
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)
if (def.raw?.isDiy && !def.diyFor) { this.log(`自选 slot ${inp.chessId} without a pick`); return null; }
const u = this._makeAlly(ps, def, 'op', r, c, { uid: inp.uid, dir });
u.items = [...(inp.items ?? [])];
u.carry = inp.carryState ?? null;
return u;
}
/**
* Token def: an inline def (PlayerBattleInput `def`, spawnToken `opts.def`) as given, else the data record's variant
* for the owner — `owner` = the owning ally unit (its chess and its selected skill / module: getToken(id,
* owner.defId, owner.def.loadout), DESIGN §16), a chess id, or null.
*/
_tokenDef(tokenId, owner, inline) {
if (inline) return normalizeToken(tokenId, inline);
if (!this.data.getToken) return null;
if (owner && typeof owner === 'object') return this.data.getToken(tokenId, owner.defId ?? null, owner.def?.loadout ?? null);
return this.data.getToken(tokenId, owner ?? null);
}
_makeAlly(ps, def, kind, r, c, extra = {}) {
const st = def.stats;
const u = new Unit({
id: ++this._idSeq, side: 'ally', kind, def, defId: def.id, name: def.name, ownerId: ps ? ps.playerId : null,
uid: extra.uid ?? null, ownerUnit: extra.ownerUnit ?? null, x: c, y: r, tileR: r, tileC: c,
dir: extra.dir != null ? normDir(extra.dir) : extra.facing != null ? normDir(extra.facing) : ps ? ps.dir : 'RIGHT',
base: {
maxHp: st.maxHp, atk: st.atk, def: st.def, res: st.res, aspd: st.aspd, bat: st.bat, blockCnt: st.blockCnt,
moveSpeed: 0, spRecovery: st.spRecovery, tauntLevel: st.tauntLevel, massLevel: st.massLevel,
hpRecoveryPerSec: st.hpRecoveryPerSec, cost: st.cost, respawnTime: st.respawnTime,
},
});
u.alive = false;
u.deployed = false;
u.items = [];
u.rangeGrid = def.rangeGrid;
u.player = ps;
this.units.push(u);
this.allyUnits.push(u);
if (ps) ps.units.push(u);
return u;
}
/** Resolve kit/profile/skill for a unit (called by content/index.js; safe to call again). */
_setupUnit(u, kit = null) {
if (!kit) {
try { kit = setupUnitKit(this, u, this.contentMode); } catch (e) { this._internalError('setupUnitKit', e); kit = null; }
}
u.kit = kit || {};
u.profile = resolveProfile(u.def, u.kit.trait || null);
if (u.def.untargetable) this.addBuff(u, { key: 'trait:untargetable', flags: { untargetable: true }, persist: true, allowDead: true });
// abnormal effects a summon holds (tokens.json `abnormal`, PRTS; user playtest #6 item 18): 禁疗 — no heal reaches it;
// 孤立 ("无法被同阵营选中") — no ally heal or ally selection (auras over a range) reaches it
const ab = u.def.abnormal;
if (Array.isArray(ab) && ab.length) {
const flags = {};
if (ab.includes('healFree')) flags.noHeal = true;
if (ab.includes('isolated')) { flags.isolated = true; flags.noHeal = true; }
if (Object.keys(flags).length) this.addBuff(u, { key: 'trait:abnormal', flags, persist: true, allowDead: true });
}
const spec = u.kit.skill || null;
u.skill = new SkillRuntime(this, u, u.def.skill, spec, u.def.skill?.bb ?? {});
if (u.profile.install && !u._profInstalled) {
u._profInstalled = true;
this._safe(() => u.profile.install(this, u), 'profile.install', u);
}
for (const t of u.kit.talents || []) {
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);
return u.kit;
}
}