Files
ark-stronghold-protocol/server/sim/units.js
T
sganggs 15291c2293 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
2026-09-30 19:12:36 +08:00

223 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, 10, 600) (base is 100 for almost everyone)
// 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;
// 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)}>`;
}
}