Files
ark-stronghold-protocol/server/sim/snapshot.js
T
sganggs 6bfc08270c 0.2.4-dev: round-29 community fixes and the official shared card pool
- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap.
- 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope).
- 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki).
- Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video).
- 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane).
- AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons.
- golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
2026-10-11 01:58:52 +08:00

185 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/snapshot.js — compact serialization for clients (DESIGN §8.2).
//
// b.snap = { fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, resolved }
// (hp of a countdown summon — unit.countdown, content/tokens.js startCountdown — is maxHp × the share of its life left)
// UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?,
// form?, skillIndex?, ammoSkill?, moduleId?, items?, standInFor?, diy? } (ammoSkill = an ally whose skill is an ammo
// magazine: the HP bar shows its rounds as cells, b.snap `ammo`; standInFor = the replaced operator's charId of a 补位
// stand-in; diy = a 自选 piece's pick { charId, skillIndex, uniEquipId } — defId is its slot, spine / avatar the operator's;
// form = the unit's current model form — an enemy's, content/enemies/helpers.js setForm:
// 掠海漂移体 'crawl', 暴鸰 'bombed', 转译基底·α's forms …; a 傀儡师 fighting as its 替身 'doll', professions.js — a view built
// after the change, a field opened mid-battle, draws it: render/units.js FORMS)
// dir = 'UP'|'RIGHT'|'DOWN'|'LEFT' (allies: the deploy direction, sim/dir.js); facing = its horizontal sign (±1).
// items = an ally operator's equipped item ids (absent without any).
// flags bits & anim codes come from shared/constants.js (UF / ANIM); an enemy's stealth bit = its 隐匿 is on (not while it
// is blocked or revealed, nor in the seconds after a block before it hides again — targeting.js enemyStealthed), an
// ally's = 隐匿 / 迷彩 whatever it blocks.
import { UF, ANIM } from '../../shared/constants.js';
import { DIE_ANIM_TIME, ATTACK_ANIM_TIME, DEPLOY_ANIM_TIME } from './constants.js';
import { enemyStealthed } from './targeting.js';
const r2 = (v) => Math.round(v * 100) / 100;
const r1 = (v) => Math.round(v * 10) / 10;
/** Static per-unit info sent on spawn / in m.field. */
export function unitInfo(u) {
const d = u.def || {};
return {
id: u.id,
kind: u.kind,
side: u.side,
ownerId: u.ownerId ?? null,
defId: u.defId,
name: u.name,
tier: d.tier ?? (d.rank === 'BOSS' ? 3 : d.rank === 'ELITE' ? 2 : 1),
golden: !!d.golden,
spine: d.spine ?? d.charId ?? u.defId,
avatar: d.avatar ?? d.charId ?? u.defId,
x: r2(u.x),
y: r2(u.y),
facing: u.facing ?? 1,
dir: u.dir ?? 'RIGHT',
maxHp: Math.max(1, Math.round(u.s.maxHp)),
motion: u.motion === 'FLY' ? 'FLY' : undefined,
boss: u.isBoss ? true : undefined,
// the unit's current model form (an enemy's content/enemies/helpers.js setForm, a 傀儡师's 替身 — render/units.js FORMS): a
// view built mid-battle (fieldMeta — a watched teammate's field, 联防 observers, a reconnect) starts on that clip set
form: typeof u.form === 'string' ? u.form : undefined,
uid: u.uid ?? undefined,
// DESIGN §16: the equipped skill's index (the renderer / audio pick that skill's Spine clip and sound)
skillIndex: u.side === 'ally' && Number.isInteger(d.skill?.index) ? d.skill.index : undefined,
// an ally whose skill is an ammo magazine: the renderer shows it as the segmented bar under the HP bar (b.snap `ammo`) and
// draws no sustained skill aura for it — known from the unit's first appearance, before any snapshot or skill event
ammoSkill: u.side === 'ally' && u.skill?.kind === 'ammo' ? true : undefined,
// DESIGN §16: an elite ally's equipped module (uniEquipId | 'none'; display only — a teammate's unit in a shared
// field shows its owner's module in the detail card)
moduleId: u.side === 'ally' && d.golden && typeof d.loadout?.moduleId === 'string' ? d.loadout.moduleId : undefined,
// an ally operator's equipped item ids (display: a 变形同构体 wearer counts for the bond it grants — the bond popup's
// member list and the detail card's bond chips of a teammate's unit)
items: u.side === 'ally' && u.kind === 'op' && Array.isArray(u.items) && u.items.length ? [...u.items] : undefined,
// 补位: the replaced operator's charId when the chess fights as its stand-in (spine / avatar are the stand-in's; the
// detail card composes the stand-in record, shared/standIn.js standInRecord)
standInFor: u.side === 'ally' && u.kind === 'op' && typeof d.standInFor === 'string' ? d.standInFor : undefined,
// 自选: the pick of a DIY slot's piece (the detail card composes its record, shared/diy.js diyRecord)
diy: u.side === 'ally' && u.kind === 'op' && d.diyFor && d.loadout?.diy ? { ...d.loadout.diy } : undefined,
// 0.2.2: an ally operator's potential below 6 and its 练度 tier (a teammate's unit shows ITS owner's numbers; absent:
// full potential, no 练度 — a stand-in, a prototype 自选 pick)
potential: u.side === 'ally' && u.kind === 'op' && d.loadout?.potentialIsDefault === false ? d.loadout.potential : undefined,
cultivate: u.side === 'ally' && u.kind === 'op' && Number.isInteger(u.cultivate) ? u.cultivate : undefined,
};
}
/** Status flag bitmask. */
export function flagsOf(u) {
const f = u.s.flags;
let bits = 0;
if (u.side === 'enemy' ? !!u.blockedBy : u.blocking.length > 0) bits |= UF.BLOCKED;
if (f.stun && !f.freeze && !f.sleep) bits |= UF.STUNNED;
if (f.freeze) bits |= UF.FROZEN;
// 隐匿 or 迷彩 (buffs.js camou), shown the see-through way. An ally keeps it while blocking (targeting.js
// canTargetAlly); an enemy's 隐匿 is off while it is blocked or revealed and until it hides again after a block (PRTS
// 作战机制 §隐匿 "在被阻挡时开关会被关掉从而失去隐匿,阻挡状态解除后3s开关重新被开启"; targeting.js enemyStealthed): a
// blocked 逐火 余烬 is drawn solid while the team beats it, and for 3 s after it slips away
if (u.side === 'enemy' ? (f.stealth && enemyStealthed(u)) || f.camou : f.stealth || f.camou) bits |= UF.STEALTH;
if (u.skill && u.skill.active && u.skill.kind !== 'passive') bits |= UF.SKILL;
if (u.s.shield > 0 || u.buffs.some((b) => b.shieldHits > 0)) bits |= UF.SHIELD;
if (f.invulnerable) bits |= UF.INVULN;
if (f.cold) bits |= UF.COLD;
if (f.sleep) bits |= UF.SLEEP;
if (u.motion === 'FLY') bits |= UF.FLYING;
return bits;
}
/** Animation code. */
export function animOf(u, t) {
if (!u.alive) return ANIM.DIE;
if (u.s.flags.stun) return ANIM.STUN;
if (t - u.deployedAt < DEPLOY_ANIM_TIME && u.side === 'ally') return ANIM.DEPLOY;
if (t < (u.skillAnimUntil ?? -1)) return ANIM.SKILL;
if (t - u.lastAttackAt < ATTACK_ANIM_TIME) return u.skill && u.skill.active && u.skill.kind !== 'passive' ? ANIM.SKILL : ANIM.ATTACK;
if (u.side === 'enemy' && u.moving && !u.blockedBy) return ANIM.MOVE;
return ANIM.IDLE;
}
/** Snapshot tuple for one unit. */
export function unitTuple(u, t) {
const sk = u.skill;
let spMax = sk && !sk.noSkill ? sk.spCost : 0;
let sp = sk && !sk.noSkill ? sk.sp : 0;
if (sk && sk.active && sk.isTimed) {
// show remaining duration/ammo as a draining bar — ammo out of the activation's real total (base + bullets added:
// 拉特兰, 逃犯引渡手续, refills; community report #35), so every bullet shortens it
if (sk.kind === 'ammo') sp = spMax * (sk.ammoLeft / Math.max(1, sk.ammoMax || sk.ammo || 0, sk.ammoLeft));
else if (Number.isFinite(sk.timeLeft) && sk.duration > 0) {
if (spMax === 0) spMax = sk.duration;
sp = spMax * (sk.timeLeft / sk.duration);
}
}
// hp is rounded up (a living unit never shows 0) but never above the rounded max HP. A countdown summon (医疗探机, 海嗣 …:
// content/tokens.js startCountdown) shows the share of its life left instead — its bar runs down like a timer and it
// leaves when it is empty (community report of 2026-10-06; its HP itself never moves: 无敌, 禁疗)
const maxHp = Math.max(1, Math.round(u.s.maxHp));
const cd = u.alive ? u.countdown : null;
const left = cd ? Math.max(0, Math.min(1, (cd.until - t) / Math.max(1e-9, cd.until - cd.from))) : 1;
const hp = u.alive ? Math.min(Math.max(1, Math.ceil(cd ? maxHp * left : u.hp)), maxHp) : 0;
return [u.id, r2(u.x), r2(u.y), hp, maxHp, r1(sp), spMax, flagsOf(u), animOf(u, t)];
}
/**
* The three optional HUD readouts of b.snap (events.js snapshot(): `ammo`, `wolves`, `neg`; each only for a living,
* deployed, visible unit and only when it has one). They ride beside the 9-field tuples — the tuple, and the SP
* fraction the ammo skill still puts in it, stay as they are.
*
* `ammoView(u)` = `[rounds left, rounds in the magazine]` of a running ammo skill (whole numbers; the magazine is the
* activation's real total — extra bullets and refills included — that unitTuple's SP fraction runs on): the segmented
* yellow bar under the HP bar (隐现's S2 holds 14 rounds, skill_table skchr_inside_2 `attack@trigger_time`).
*/
export function ammoView(u) {
const sk = u.skill;
if (u.side !== 'ally' || !sk || !sk.active || sk.kind !== 'ammo') return null;
const left = Math.max(0, Math.ceil(sk.ammoLeft - 1e-9));
return [left, Math.max(1, Math.ceil((sk.ammoMax || sk.ammo || 0) - 1e-9), left)];
}
/** 琳琅诗怀雅's skill purse: zero is a balance; no purse is null. Display only. */
export function coinView(u) {
const n = u.mem?.coins, max = u.skill?.bb?.sp;
if (u.side !== 'ally' || !Number.isSafeInteger(n) || n < 0 || !Number.isSafeInteger(max) || max < 1) return null;
return [Math.min(n, max), max];
}
/**
* `wolfView(u)` = `[狼影 left, the talent's maximum]` of 伺夜's 狼群 pack (content/tokens.js wolfPack and the managed kit
* chess_char_3_19-vigil.js keep the count in `mem.shadows` and the maximum in `mem.wolfCapacity`): the pips under the HP
* bar. A pack in its 战术点形态 is not alive and shows nothing.
*/
export function wolfView(u) {
const cap = u.mem?.wolfCapacity, n = u.mem?.shadows;
if (u.side !== 'ally' || !(cap >= 1) || !Number.isFinite(n)) return null;
const max = Math.round(cap);
return [Math.max(0, Math.min(max, Math.round(n))), max];
}
/**
* `negView(u)` = the share (0.01–1) of its cap that a unit's negative-HP pool holds, null without a pool: 斩业星熊's 我执
* (kits/ops/op-hsgma2.js sets `unit.negFill`, the pool read when the snapshot is taken) — the red bar over the drained HP
* bar. The pool itself, and the 1-HP floor she stands on, are the sim's and are not touched.
*/
export function negView(u) {
if (typeof u.negFill !== 'function') return null;
const v = u.negFill();
return v > 0 ? Math.min(1, Math.max(0.01, r2(v))) : null;
}
/** Units included in a snapshot: deployed & visible, plus recently dead ones (DIE animation). */
export function snapshotUnits(units, t) {
const out = [];
for (const u of units) {
if (u.hidden) continue;
if (u.alive && u.deployed) out.push(unitTuple(u, t));
else if (!u.alive && t - u.deathAt < DIE_ANIM_TIME && u.deathAt > -Infinity) out.push(unitTuple(u, t));
}
return out;
}