// 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; }