Files
ark-stronghold-protocol/server/sim/units.js
T
sganggs 5a3d562b75 feedback1f WV: a knocked-out operator keeps its tile; 突袭 never lands on it
Player report F5 after 0.1.0: "突袭盟约的干员会移动到濒死干员的尸体上".

Real. A knocked-out operator left the occupancy map at once and only its
home tile stayed reserved. A 突袭 member that fell after its jump lay on the
landing tile, which nothing reserved, so the next member's jump (same target,
same scoring) landed on it; and a member's timed redeploy came back on its home
where a teammate that had jumped there lay. Real matches (six real 突袭
members, 战场#07 / #08, R6 / R9 / R12, 96 battles): 6 placements on a body
(3 jumps, 3 home redeploys) before, 0 of 526 jumps after.

Official (PRTS 卫戍协议/帮助, 作战阶段 / 单位部署):
- "干员退场后…原地留下一个“倒地干员”…满足再部署条件时,移除场上的该倒地干员
  并自动部署至该位置"
- "倒地干员所在地块视为可部署,但所有我方单位在此处的部署行为将被阻止"
- "若干员被击倒的位置为其他干员或召唤物的初始位置,则在被击倒后,尝试返回其
  自身的初始位置"

Now (sim/Battle.js):
- A knocked-out operator lies on `unit.body`: the tile it fell on, or its
  own home when it fell on another board piece's home and its home is free
  (one attempt at the knock-out, assumed). x / y / tileR / tileC keep where
  it fell, so the kill / death handlers still act there.
- Every redeploy comes back there (restTile): the timed one, 不屈's and
  阿戈尔's. A retreated operator still comes back on its home.
- downOn(r, c): _deploy (redeploys, the 突袭 landing, summons), spawnDevice
  and relocate refuse the tile for every ally of the field; isReservedTile
  reports it, so every automatic picker skips it (突袭 raidTile, tactical
  points, summon / device pickers, 维娜's lions). The kit helpers that kept
  their own home-tile loop (tier1 summonTileFree, tier3 / tier4 freeTile) go
  through isReservedTile.
- b.snap `down` entries gain the body tile: [id, respawnAt, respawnTime,
  state, row, col]. DOWN_STATE.WAIT_TILE becomes a safeguard only.

Tests: test/sim/feedback1f-downed-tile.test.js (real Match / PlayerState /
BattleSpec runs with real 突袭 chess, the 突袭 jump, the engine refusals,
the return home); downelem and bonds_addon follow the official rule.
2026-10-03 07:16:24 +08:00

226 lines
11 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/units.js — Unit model (operators, tokens, enemies, devices) and stat aggregation (DESIGN §5.2).
//
// Aggregation (recomputed lazily whenever buffs change — `unit.markDirty()`):
// ATK/DEF/maxHp = (base + Σflat) × (1 + Σpct) × Πmul
// res = clamp((base + Σflat) × Πmul, 0, 100)
// aspd = clamp(base + Σaspd, 20, 600) (base is 100 for almost everyone; floor 20 = PRTS 数值范围)
// interval = bat × (1 + ΣbatPct) × 100 / aspd (ΣbatPct floored at −0.9)
// moveSpeed = (base + ΣmoveFlat) × ΠmoveMul (tiles/s = moveSpeed × MOVE_SCALE)
// massLevel = max(0, base + ΣmassFlat) (重量: displacement, 浮空 halving; 失重 = massFlat −1)
// Changing maxHp keeps the HP ratio. The sim keeps floats; rounding happens only in snapshots.
import { aggregateMods } from './buffs.js';
import { flagsOf } from './snapshot.js';
import { ASPD_MIN, ASPD_MAX, ELEMENT_GAUGE_MAX, ELEMENT_GAUGE_MAX_LEADER } from './constants.js';
import { DIR_VEC, normDir } from './dir.js';
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
const EMPTY = Object.freeze({});
/** Aggregates can overflow (huge stacked *Mul mods → Infinity) — fall back to `d` so no stat is ever non-finite. */
const fin = (v, d) => (Number.isFinite(v) ? v : d);
export class Unit {
/**
* @param {object} init
*/
constructor(init) {
// Heavy references are non-enumerable so assertion diffs / logs never walk the whole battle graph.
for (const k of ['def', 'player', 'ownerUnit', 'kit', 'profile', 'skill', 'route', 'bossPool', 'blockedBy', 'blocking', 'carry', 'mem', 'trait']) {
Object.defineProperty(this, k, { value: null, writable: true, enumerable: false, configurable: true });
}
this.id = init.id;
this.side = init.side; // 'ally' | 'enemy'
this.kind = init.kind; // 'op' | 'token' | 'enemy' | 'device'
this.def = init.def; // normalised def (chess / token / enemy / device)
this.defId = init.defId ?? init.def?.id ?? 'unknown';
this.name = init.name ?? init.def?.name ?? this.defId;
this.ownerId = init.ownerId ?? null; // playerId (enemies: field owner)
this.uid = init.uid ?? null; // match piece uid (ops/tokens from the board)
this.ownerUnit = init.ownerUnit ?? null; // summoner (tokens)
this.x = init.x ?? 0;
this.y = init.y ?? 0;
this.tileR = init.tileR ?? Math.round(this.y);
this.tileC = init.tileC ?? Math.round(this.x);
this.homeR = this.tileR;
this.homeC = this.tileC;
// knocked-out operators (Battle.isDown): [r, c] of the tile the body lies on and the unit comes back on — where it
// fell, or its home (Battle._layBody); x / y / tileR / tileC keep where it fell
this.body = null;
// deploy direction (sim/dir.js; allies: from the board piece, default RIGHT); `facing` is its derived horizontal
// sign (sprite flip only) — a legacy `init.facing` ±1 still maps to RIGHT / LEFT
this.dir = normDir(init.dir ?? init.facing);
this.base = {
maxHp: 1, atk: 0, def: 0, res: 0, aspd: 100, bat: 1, blockCnt: 0, moveSpeed: 0, spRecovery: 1,
tauntLevel: 0, massLevel: 0, hpRecoveryPerSec: 0, cost: 0, respawnTime: 0, rangeRadius: 0,
...(init.base || {}),
};
/** @type {object[]} */
this.buffs = [];
this._dirty = true;
this._s = null;
this.hp = this.base.maxHp;
this.alive = true;
this.deployed = false;
this.removed = false; // permanently gone (enemies, tokens, retreated-for-good)
this.blocking = []; // ops: enemies currently blocked
this.blockedBy = null; // enemies: blocker unit
this.motion = init.motion ?? 'GROUND'; // enemies: 'WALK' | 'FLY'
this.profile = null; // resolved profession/attack profile
this.skill = null; // SkillRuntime
this.kit = null;
this.atkCd = 0;
this.lastAttackAt = -Infinity;
this.lastHitAt = -Infinity;
this.deployedAt = -Infinity;
this.deathAt = -Infinity;
this.respawnAt = Infinity;
this.deploySeq = 0; // deployment counter; also the identity of one deployment (content: `seq === u.deploySeq`)
this.aggroSeq = 0; // 仇恨 order (the later the more attacked): = deploySeq, except summons of the initial deployment
this.spawnSeq = 0;
this.elem = { burn: 0, neural: 0, necrosis: 0, apoptosis: 0, erosion: 0 };
this.burstPending = null; // { [element]: true } while that element's burst resolves (damage.js burstLocked)
this.tags = new Set(init.tags || []);
this.mem = {}; // free scratch space for content (per unit)
this.trait = {}; // profession runtime state
this.stats = { dmg: 0, kills: 0, heal: 0, taken: 0, attacks: 0 };
this.hidden = false; // enemies inside a DISAPPEAR segment
this.anim = 0;
this.persist = { redeployMul: 1, freeRedeploys: 0 };
this.isBoss = false;
this.bossPool = null;
}
markDirty() { this._dirty = true; }
/** Horizontal sign of the direction (LEFT ⇒ −1, else +1): sprite flip only — geometry uses `dir` / `fwd`. */
get facing() { return this.dir === 'LEFT' ? -1 : 1; }
/** Legacy setter: ±1 ⇒ RIGHT / LEFT (prefer assigning `dir`). */
set facing(v) { this.dir = normDir(v, this.dir); }
/** Forward vector [dRow, dCol] (UP = +row). */
get fwd() { return DIR_VEC[this.dir] ?? DIR_VEC.RIGHT; }
/** Aggregated stats (lazy). */
get s() {
if (this._dirty) this._recalc();
return this._s;
}
get maxHp() { return this.s.maxHp; }
get atk() { return this.s.atk; }
_recalc() {
const prevMax = this._s ? this._s.maxHp : this.base.maxHp;
const { add, mul, flags, shield, permRangeExtend } = aggregateMods(this.buffs);
const a = (k) => add[k] ?? 0;
const m = (k) => mul[k] ?? 1;
const b = this.base;
const bHp = fin(b.maxHp, 1) > 0 ? fin(b.maxHp, 1) : 1;
const maxHp = Math.max(1, fin((bHp + a('hpFlat')) * Math.max(0, 1 + a('hpPct')) * m('hpMul'), bHp));
const atk = Math.max(0, fin((b.atk + a('atkFlat')) * Math.max(0, 1 + a('atkPct')) * m('atkMul'), fin(b.atk, 0)));
const def = Math.max(0, fin((b.def + a('defFlat')) * Math.max(0, 1 + a('defPct')) * m('defMul'), fin(b.def, 0)));
const res = clamp(fin((b.res + a('resFlat')) * m('resMul'), fin(b.res, 0)), 0, 100);
const aspd = clamp(fin(b.aspd + a('aspd'), 100), ASPD_MIN, ASPD_MAX);
const bBat = fin(b.bat, 1) > 0 ? fin(b.bat, 1) : 1;
const bat = fin(bBat * Math.max(0.1, 1 + a('batPct')), bBat);
const s = {
maxHp, atk, def, res, aspd, bat,
interval: (bat * 100) / aspd,
blockCnt: Math.max(0, fin(Math.round(b.blockCnt + a('blockCnt')), 0)),
moveSpeed: Math.max(0, fin((b.moveSpeed + a('moveFlat')) * m('moveMul'), fin(b.moveSpeed, 0))),
rangeExtend: Math.max(0, Math.round(a('rangeExtend'))),
baseRangeExtend: Math.max(0, fin(Math.round(permRangeExtend), 0)), // permanent part (initial range)
massLevel: Math.max(0, fin(fin(b.massLevel, 0) + a('massFlat'), 0)),
maxTargets: a('maxTargets'),
// DESIGN §5.3 lists `taunt` as a buff flag (+aggro) as well as a numeric mod: the flag counts as +1 level
taunt: (Number.isFinite(b.tauntLevel) ? b.tauntLevel : 0) + a('taunt') + (flags.taunt === true ? 1 : 0),
dodgePhys: clamp(a('dodgePhys'), 0, 1),
dodgeArts: clamp(a('dodgeArts'), 0, 1),
defIgnoreFlat: a('defIgnoreFlat'),
defIgnorePct: clamp(a('defIgnorePct'), 0, 1),
resIgnoreFlat: a('resIgnoreFlat'),
resIgnorePct: clamp(a('resIgnorePct'), 0, 1),
dmgDealtMul: m('dmgDealtMul'),
physDealtMul: m('physDealtMul'),
artsDealtMul: m('artsDealtMul'),
dmgTakenMul: m('dmgTakenMul'),
physTakenMul: m('physTakenMul'),
artsTakenMul: m('artsTakenMul'),
trueTakenMul: m('trueTakenMul'),
elemTakenMul: m('elemTakenMul'), // 元素损伤倍率: element gauge fills (damage.js applyElement)
elementalTakenMul: m('elementalTakenMul'), // 元素脆弱: 元素伤害 (the 'elemental' HP damage type)
healingDealtMul: m('healingDealtMul'),
healingTakenMul: m('healingTakenMul'),
atkScaleMul: m('atkScaleMul'),
spRecovery: Math.max(0, fin((b.spRecovery + a('spRecoveryFlat')) * m('spRecoveryMul'), 0)),
spCostFlat: a('spCostFlat'),
redeployMul: m('redeployMul'),
hpRegen: fin(b.hpRecoveryPerSec + a('hpRegen') + a('hpRegenRatio') * maxHp, 0),
shield,
flags: flags || EMPTY,
};
s.flags.stun = !!(flags.stun || flags.freeze || flags.sleep || flags.levitate);
this._s = s;
this._dirty = false;
if (this.alive && prevMax > 0 && Math.abs(prevMax - maxHp) > 1e-9) {
this.hp = clamp(fin(this.hp * (maxHp / prevMax), maxHp), 0, maxHp);
}
}
/** Current flags object (stun/freeze/sleep/…) */
get flags() { return this.s.flags; }
/** Can act at all this tick (not stunned/frozen/sleeping/levitated). */
get canAct() { return this.alive && this.deployed && !this.hidden && !this.s.flags.stun; }
hasFlag(k) { return !!this.s.flags[k]; }
/**
* Air unit (空中单位) for every targeting / ground-only rule: FLY movers, and enemies that hover (近地悬浮, buff flag
* `float` — PRTS 术语释义 ba.float "算作空中单位"; they keep walking the ground path) or are levitated (浮空, gamedata_const
* ba.levitate "变为空中单位"). Movement and pathing read `motion`, never this.
*/
get isFlying() {
if (this.motion === 'FLY') return true;
if (this.side !== 'enemy') return false;
const f = this.s.flags;
return !!(f.float || f.levitate);
}
get hpRatio() { const mh = this.s.maxHp; return mh > 0 ? this.hp / mh : 0; }
/** Snapshot status bitmask (UF bits: blocked, stunned, frozen, stealth, skill, shield, invuln, cold, sleep, flying). */
get statusFlags() { return flagsOf(this); }
/** Damage type of the unit's normal attack ('phys' | 'arts' | 'true' | 'heal' | 'none'). */
get dmgType() { return (this.profile && this.profile.dmgType) || (this.def && this.def.dmgType) || 'phys'; }
/** Displacement weight (= current massLevel incl. `massFlat` buffs). Enemies also carry `lpr` (LP cost) as a plain field. */
get weight() { return this.s.massLevel; }
get spMax() { return this.skill ? this.skill.spCost : 0; }
get sp() { return this.skill ? this.skill.sp : 0; }
isOp() { return this.kind === 'op'; }
isEnemy() { return this.side === 'enemy'; }
isAlly() { return this.side === 'ally'; }
/** Element gauge capacity: 1000, leaders (enemy rank BOSS) 2000 (official "·我方" burst terms). */
get gaugeMax() {
return this.side === 'enemy' && (this.isBoss || (this.def && this.def.rank === 'BOSS')) ? ELEMENT_GAUGE_MAX_LEADER : ELEMENT_GAUGE_MAX;
}
/** Element gauge helper */
elemRatio(el) { return (this.elem[el] ?? 0) / this.gaugeMax; }
findBuff(key) {
for (const b of this.buffs) if (b.key === key) return b;
return null;
}
hasBuff(key) { return this.findBuff(key) !== null; }
toString() { return `${this.kind}#${this.id}(${this.name})`; }
/** Compact inspection (avoids dumping the whole battle graph in assertion messages / logs). */
[Symbol.for('nodejs.util.inspect.custom')]() {
return `Unit<${this.kind}#${this.id} ${this.defId} hp=${Math.round(this.hp)}${this.alive ? '' : ' dead'} @${this.x.toFixed(2)},${this.y.toFixed(2)}>`;
}
}