Skip to main content

boon/entity/
entities.rs

1use std::collections::HashSet;
2
3use rustc_hash::FxHashMap;
4
5use crate::error::{Error, Result};
6use crate::io::BitReader;
7
8use super::class_info::ClassInfo;
9use super::field_decoder::FieldDecodeContext;
10use super::field_path::{self, FieldPath};
11use super::field_value::FieldValue;
12use super::serializers::{Serializer, SerializerContainer};
13use super::string_tables::StringTableContainer;
14
15use boon_proto::proto::CsvcMsgPacketEntities;
16
17// Serial-number layout for the entity *create* delta (legacy Source CBaseHandle):
18// a create carries a 15-bit entity entry (`MAX_EDICT_BITS + 1`) followed by a
19// 17-bit serial number. This governs only the serial read in `handle_create`; it
20// is NOT the layout of a networked CHandle *field value*, whose entity index is
21// the low 14 bits — see [`ENTITY_HANDLE_INDEX_MASK`] / [`EntityContainer::get_by_handle`].
22const MAX_EDICT_BITS: u32 = 14;
23const NUM_ENT_ENTRY_BITS: u32 = MAX_EDICT_BITS + 1;
24const NUM_SERIAL_NUM_BITS: u32 = 32 - NUM_ENT_ENTRY_BITS;
25
26/// Mask that extracts the entity-array index from a networked `CHandle` value.
27///
28/// Source 2 packs a 14-bit edict index (entity indices run 0–16383) in the low
29/// bits of a handle with a serial number above it, so the index is recovered
30/// with this 14-bit mask. A wider mask (e.g. `0x7FFF`) leaks a serial bit into
31/// the index and resolves the wrong entity for any handle with an odd serial.
32pub const ENTITY_HANDLE_INDEX_MASK: u32 = 0x3FFF;
33
34/// Sentinel value for a `CHandle` field that points at no entity.
35///
36/// Protobuf handle fields (e.g. `modifier.parent`) are optional; callers
37/// substitute this when the field is absent and skip handles equal to it.
38pub const INVALID_ENTITY_HANDLE: u32 = 0x00FF_FFFF;
39
40/// Resolve a protobuf-style optional `CHandle` to an entity-array index.
41///
42/// Returns `None` when the handle is absent (the protobuf field was not set)
43/// or holds the [`INVALID_ENTITY_HANDLE`] sentinel; otherwise applies
44/// [`ENTITY_HANDLE_INDEX_MASK`] and returns the entity-array index. Use this
45/// for `Option<u32>` fields on `CitadelUserMessage`s such as `modifier.parent`
46/// or `msg.player`; it pairs naturally with `let-else`:
47///
48/// ```ignore
49/// let Some(parent_idx) = boon::protobuf_handle_index(modifier.parent) else { continue };
50/// ```
51pub fn protobuf_handle_index(handle: Option<u32>) -> Option<i32> {
52    handle
53        .filter(|&h| h != INVALID_ENTITY_HANDLE)
54        .map(|h| (h & ENTITY_HANDLE_INDEX_MASK) as i32)
55}
56
57/// Delta header values (2-bit codes) indicating entity state changes.
58const DELTA_UPDATE: u8 = 0b00;
59const DELTA_CREATE: u8 = 0b10;
60const DELTA_LEAVE: u8 = 0b01;
61const DELTA_DELETE: u8 = 0b11;
62
63/// A single entity with its class, fields, and current state.
64#[derive(Debug, Clone)]
65pub struct Entity {
66    /// Slot index in the entity array (0–16383).
67    pub index: i32,
68    /// Serial number for this slot (increments on reuse).
69    pub serial: u32,
70    /// Numeric class ID (indexes into [`ClassInfo`]).
71    pub class_id: i32,
72    /// Network class name (e.g. `"CCitadelPlayerController"`).
73    pub class_name: String,
74    /// Current field values, keyed by packed field path keys.
75    pub fields: FxHashMap<u64, FieldValue>,
76}
77
78impl Entity {
79    fn new(index: i32, class_id: i32, class_name: String) -> Self {
80        Self {
81            index,
82            serial: 0,
83            class_id,
84            class_name,
85            fields: FxHashMap::default(),
86        }
87    }
88
89    /// Apply field path deltas from a bit reader using the given serializer.
90    #[allow(clippy::needless_range_loop)]
91    fn apply_update(
92        &mut self,
93        br: &mut BitReader,
94        serializer: &Serializer,
95        ctx: &mut FieldDecodeContext,
96        fp_buf: &mut Vec<FieldPath>,
97    ) -> Result<()> {
98        field_path::read_field_paths(br, fp_buf)?;
99
100        for fp_idx in 0..fp_buf.len() {
101            // Walk the serializer hierarchy to find the decoder (same as skip_update)
102            let fp_last = fp_buf[fp_idx].last;
103            let mut field = &serializer.fields[fp_buf[fp_idx].get(0)];
104
105            for i in 1..=fp_last {
106                let idx = fp_buf[fp_idx].get(i);
107                if field.is_dynamic_array() {
108                    if let Some(ref fs) = field.field_serializer {
109                        field = &fs.fields[0];
110                    }
111                } else if let Some(ref fs) = field.field_serializer {
112                    field = &fs.fields[idx];
113                } else {
114                    break;
115                }
116            }
117
118            let key = fp_buf[fp_idx].pack();
119            let value = field
120                .metadata
121                .decoder
122                .decode(ctx, br)
123                .map_err(|e| Error::Parse {
124                    context: format!(
125                        "field #{} key={:#x} (type: {}, decoder: {:?}, pos: {}, remaining: {}): {}",
126                        fp_idx,
127                        key,
128                        field.var_type,
129                        field.metadata.decoder,
130                        br.position(),
131                        br.bits_remaining(),
132                        e
133                    ),
134                })?;
135            self.fields.insert(key, value);
136        }
137
138        Ok(())
139    }
140
141    /// Skip field updates - reads the data to advance the bit reader but doesn't store anything.
142    /// This avoids allocations and FxHashMap insertions for entities we don't care about.
143    #[allow(clippy::needless_range_loop)]
144    fn skip_update(
145        br: &mut BitReader,
146        serializer: &Serializer,
147        ctx: &mut FieldDecodeContext,
148        fp_buf: &mut Vec<FieldPath>,
149    ) -> Result<()> {
150        field_path::read_field_paths(br, fp_buf)?;
151
152        for fp_idx in 0..fp_buf.len() {
153            // Walk the serializer hierarchy to find the decoder
154            let fp_last = fp_buf[fp_idx].last;
155            let mut field = &serializer.fields[fp_buf[fp_idx].get(0)];
156
157            for i in 1..=fp_last {
158                let idx = fp_buf[fp_idx].get(i);
159                if field.is_dynamic_array() {
160                    if let Some(ref fs) = field.field_serializer {
161                        field = &fs.fields[0];
162                    }
163                } else if let Some(ref fs) = field.field_serializer {
164                    field = &fs.fields[idx];
165                } else {
166                    break;
167                }
168            }
169
170            // Skip the value - just advances the bit reader without decoding
171            field.metadata.decoder.skip(ctx, br)?;
172        }
173
174        Ok(())
175    }
176
177    /// Look up a field by its dotted name string using the serializer to resolve the key.
178    pub fn get_by_name(&self, path: &str, serializer: &Serializer) -> Option<&FieldValue> {
179        let key = serializer.resolve_field_key(path)?;
180        self.fields.get(&key)
181    }
182
183    // ── Typed field accessors ──
184    //
185    // Each takes a field key pre-resolved with [`Serializer::resolve_field_key`]
186    // (so it can be resolved once and reused across ticks) and reads the field
187    // leniently: a missing key, an absent field, or a value of a different type
188    // all yield the type's default rather than an error. Integer variants accept
189    // any of the four integer encodings, since the network type is not always
190    // known ahead of time.
191
192    /// Read a field as `i64`, returning `0` when absent or non-integer.
193    pub fn get_i64(&self, key: Option<u64>) -> i64 {
194        key.and_then(|k| self.fields.get(&k))
195            .and_then(|v| match v {
196                FieldValue::U32(n) => Some(*n as i64),
197                FieldValue::U64(n) => Some(*n as i64),
198                FieldValue::I32(n) => Some(*n as i64),
199                FieldValue::I64(n) => Some(*n),
200                _ => None,
201            })
202            .unwrap_or(0)
203    }
204
205    /// Read a field as `u32`, returning `0` when absent or non-integer.
206    pub fn get_u32(&self, key: Option<u64>) -> u32 {
207        key.and_then(|k| self.fields.get(&k))
208            .and_then(|v| match v {
209                FieldValue::U32(n) => Some(*n),
210                FieldValue::U64(n) => Some(*n as u32),
211                FieldValue::I32(n) => Some(*n as u32),
212                FieldValue::I64(n) => Some(*n as u32),
213                _ => None,
214            })
215            .unwrap_or(0)
216    }
217
218    /// Read a field as `f32`, returning `0.0` when absent or non-float.
219    pub fn get_f32(&self, key: Option<u64>) -> f32 {
220        key.and_then(|k| self.fields.get(&k))
221            .and_then(|v| match v {
222                FieldValue::F32(f) => Some(*f),
223                _ => None,
224            })
225            .unwrap_or(0.0)
226    }
227
228    /// Read a field as `bool`, returning `false` when absent or non-bool.
229    pub fn get_bool(&self, key: Option<u64>) -> bool {
230        key.and_then(|k| self.fields.get(&k))
231            .and_then(|v| match v {
232                FieldValue::Bool(b) => Some(*b),
233                _ => None,
234            })
235            .unwrap_or(false)
236    }
237
238    /// Read a field as a `QAngle`, returning `[0.0; 3]` when absent or non-angle.
239    pub fn get_qangle(&self, key: Option<u64>) -> [f32; 3] {
240        key.and_then(|k| self.fields.get(&k))
241            .and_then(|v| match v {
242                FieldValue::QAngle(a) => Some(*a),
243                _ => None,
244            })
245            .unwrap_or([0.0; 3])
246    }
247
248    /// Combine cell + in-cell offset fields into a world-coordinate `[x, y, z]`.
249    ///
250    /// Source 2 splits each axis of an entity's position across two networked
251    /// fields — an integer cell index (e.g. `m_cellX`) and a quantized offset
252    /// inside that cell (e.g. `m_vecOrigin.m_vecX`). Pass the resolved keys
253    /// for both halves and this returns the full world position in Hammer
254    /// units via [`cell_to_world`](crate::position::cell_to_world). Reading
255    /// the offset alone gives a sawtooth that resets every cell boundary, not
256    /// a usable coordinate.
257    ///
258    /// Cell keys with no resolved field decode as cell `0`, which means the
259    /// result is shifted into a single cell-grid quadrant rather than
260    /// returning a sentinel — verify the keys before relying on it.
261    pub fn world_position(
262        &self,
263        cell_keys: [Option<u64>; 3],
264        offset_keys: [Option<u64>; 3],
265    ) -> [f32; 3] {
266        let cell = [
267            self.get_i64(cell_keys[0]) as i32,
268            self.get_i64(cell_keys[1]) as i32,
269            self.get_i64(cell_keys[2]) as i32,
270        ];
271        let offset = [
272            self.get_f32(offset_keys[0]),
273            self.get_f32(offset_keys[1]),
274            self.get_f32(offset_keys[2]),
275        ];
276        [
277            crate::position::cell_to_world(cell[0], offset[0]),
278            crate::position::cell_to_world(cell[1], offset[1]),
279            crate::position::cell_to_world(cell[2], offset[2]),
280        ]
281    }
282
283    /// Read a raw `CHandle` field as `u32`, if present.
284    ///
285    /// Pass the result to [`EntityContainer::get_by_handle`] to follow the handle
286    /// to its entity; that helper owns the index mask so callers never decode it
287    /// by hand.
288    pub fn get_handle(&self, key: Option<u64>) -> Option<u32> {
289        key.and_then(|k| self.fields.get(&k)).and_then(|v| match v {
290            FieldValue::U32(n) => Some(*n),
291            FieldValue::U64(n) => Some(*n as u32),
292            FieldValue::I32(n) => Some(*n as u32),
293            FieldValue::I64(n) => Some(*n as u32),
294            _ => None,
295        })
296    }
297}
298
299/// Container managing all active entities.
300#[derive(Default)]
301pub struct EntityContainer {
302    pub entities: FxHashMap<i32, Entity>,
303    /// Tracks class_id for entities we're not fully tracking (for filtered parsing).
304    /// This lets us skip updates properly by knowing which serializer to use.
305    skipped_entity_classes: FxHashMap<i32, i32>,
306    /// Indices of tracked entities created or updated since the last
307    /// [`clear_updated`](Self::clear_updated). Accumulates across every packet in
308    /// a tick so change-only consumers can process just what changed instead of
309    /// rescanning every active entity. The parser clears it after each per-tick
310    /// callback.
311    updated: Vec<i32>,
312}
313
314impl EntityContainer {
315    pub fn new() -> Self {
316        Self::default()
317    }
318
319    /// Handle a CSVCMsg_PacketEntities message.
320    pub fn handle_packet_entities(
321        &mut self,
322        msg: CsvcMsgPacketEntities,
323        class_info: &ClassInfo,
324        serializers: &SerializerContainer,
325        string_tables: &StringTableContainer,
326        field_decode_ctx: &mut FieldDecodeContext,
327        fp_buf: &mut Vec<FieldPath>,
328    ) -> Result<()> {
329        let entity_data = msg.entity_data.unwrap_or_default();
330        let mut br = BitReader::new(&entity_data);
331
332        let mut entity_index: i32 = -1;
333
334        for _ in 0..msg.updated_entries.unwrap_or(0) {
335            entity_index += br.read_ubitvar()? as i32 + 1;
336
337            // Read delta header (2 bits)
338            let dh = br.read_bits(2)? as u8;
339
340            match dh {
341                DELTA_CREATE => {
342                    self.handle_create(
343                        entity_index,
344                        &mut br,
345                        class_info,
346                        serializers,
347                        string_tables,
348                        field_decode_ctx,
349                        fp_buf,
350                    )
351                    .map_err(|e| Error::Parse {
352                        context: format!("entity create #{}: {}", entity_index, e),
353                    })?;
354                    self.updated.push(entity_index);
355                }
356                DELTA_UPDATE => {
357                    self.handle_update(
358                        entity_index,
359                        &mut br,
360                        class_info,
361                        serializers,
362                        field_decode_ctx,
363                        fp_buf,
364                    )
365                    .map_err(|e| Error::Parse {
366                        context: format!(
367                            "entity update #{} (class: {:?}): {}",
368                            entity_index,
369                            self.entities.get(&entity_index).map(|e| &e.class_name),
370                            e
371                        ),
372                    })?;
373                    self.updated.push(entity_index);
374                }
375                DELTA_DELETE | DELTA_LEAVE => {
376                    self.entities.remove(&entity_index);
377                }
378                _ => {}
379            }
380        }
381
382        Ok(())
383    }
384
385    /// Handle a CSVCMsg_PacketEntities message, only tracking specified entity classes.
386    /// Entities not in the filter are parsed (to advance the bit reader) but not stored.
387    #[allow(clippy::too_many_arguments)]
388    pub fn handle_packet_entities_filtered(
389        &mut self,
390        msg: CsvcMsgPacketEntities,
391        class_info: &ClassInfo,
392        serializers: &SerializerContainer,
393        string_tables: &StringTableContainer,
394        field_decode_ctx: &mut FieldDecodeContext,
395        class_filter: &HashSet<&str>,
396        fp_buf: &mut Vec<FieldPath>,
397    ) -> Result<()> {
398        let entity_data = msg.entity_data.unwrap_or_default();
399        let mut br = BitReader::new(&entity_data);
400
401        let mut entity_index: i32 = -1;
402
403        for _ in 0..msg.updated_entries.unwrap_or(0) {
404            entity_index += br.read_ubitvar()? as i32 + 1;
405
406            // Read delta header (2 bits)
407            let dh = br.read_bits(2)? as u8;
408
409            match dh {
410                DELTA_CREATE => {
411                    if self.handle_create_filtered(
412                        entity_index,
413                        &mut br,
414                        class_info,
415                        serializers,
416                        string_tables,
417                        field_decode_ctx,
418                        class_filter,
419                        fp_buf,
420                    )? {
421                        self.updated.push(entity_index);
422                    }
423                }
424                DELTA_UPDATE => {
425                    if self.handle_update_filtered(
426                        entity_index,
427                        &mut br,
428                        class_info,
429                        serializers,
430                        field_decode_ctx,
431                        class_filter,
432                        fp_buf,
433                    )? {
434                        self.updated.push(entity_index);
435                    }
436                }
437                DELTA_DELETE | DELTA_LEAVE => {
438                    self.entities.remove(&entity_index);
439                    self.skipped_entity_classes.remove(&entity_index);
440                }
441                _ => {}
442            }
443        }
444
445        Ok(())
446    }
447
448    #[allow(clippy::too_many_arguments)]
449    fn handle_create(
450        &mut self,
451        index: i32,
452        br: &mut BitReader,
453        class_info: &ClassInfo,
454        serializers: &SerializerContainer,
455        string_tables: &StringTableContainer,
456        field_decode_ctx: &mut FieldDecodeContext,
457        fp_buf: &mut Vec<FieldPath>,
458    ) -> Result<()> {
459        let class_id = br.read_bits(class_info.bits)? as i32;
460        let _serial = br.read_bits(NUM_SERIAL_NUM_BITS as usize)?;
461        let _unknown = br.read_uvarint32()?;
462
463        let class_entry = class_info.by_id(class_id).ok_or_else(|| Error::Parse {
464            context: format!("unknown class_id {}", class_id),
465        })?;
466
467        let serializer =
468            serializers
469                .get(&class_entry.network_name)
470                .ok_or_else(|| Error::Parse {
471                    context: format!("no serializer for {}", class_entry.network_name),
472                })?;
473
474        let mut entity = Entity::new(index, class_id, class_entry.network_name.clone());
475        // Pre-size the field map to the class's field count: a create applies the
476        // baseline plus the create delta, setting many fields at once, so starting
477        // from an empty map otherwise rehashes repeatedly as it grows.
478        entity.fields.reserve(serializer.fields.len());
479
480        // Apply baseline from instancebaseline string table
481        if let Some(baseline_data) = string_tables.instance_baselines.get(&class_id) {
482            let mut baseline_br = BitReader::new(baseline_data);
483            entity
484                .apply_update(&mut baseline_br, serializer, field_decode_ctx, fp_buf)
485                .map_err(|err| Error::Parse {
486                    context: format!(
487                        "baseline for {} (class_id {}): {}",
488                        class_entry.network_name, class_id, err
489                    ),
490                })?;
491        }
492
493        // Apply create delta
494        entity
495            .apply_update(br, serializer, field_decode_ctx, fp_buf)
496            .map_err(|err| Error::Parse {
497                context: format!(
498                    "create delta for {} (class_id {}): {}",
499                    class_entry.network_name, class_id, err
500                ),
501            })?;
502        self.entities.insert(index, entity);
503
504        Ok(())
505    }
506
507    fn handle_update(
508        &mut self,
509        index: i32,
510        br: &mut BitReader,
511        _class_info: &ClassInfo,
512        serializers: &SerializerContainer,
513        field_decode_ctx: &mut FieldDecodeContext,
514        fp_buf: &mut Vec<FieldPath>,
515    ) -> Result<()> {
516        let entity = match self.entities.get_mut(&index) {
517            Some(e) => e,
518            None => {
519                return Err(Error::Parse {
520                    context: format!("tried to update non-existent entity #{}", index),
521                });
522            }
523        };
524
525        let serializer = serializers
526            .get(&entity.class_name)
527            .ok_or_else(|| Error::Parse {
528                context: format!("no serializer for {}", entity.class_name),
529            })?;
530
531        entity.apply_update(br, serializer, field_decode_ctx, fp_buf)?;
532        Ok(())
533    }
534
535    /// Returns `true` if the entity's class passed the filter and was stored.
536    #[allow(clippy::too_many_arguments)]
537    fn handle_create_filtered(
538        &mut self,
539        index: i32,
540        br: &mut BitReader,
541        class_info: &ClassInfo,
542        serializers: &SerializerContainer,
543        string_tables: &StringTableContainer,
544        field_decode_ctx: &mut FieldDecodeContext,
545        class_filter: &HashSet<&str>,
546        fp_buf: &mut Vec<FieldPath>,
547    ) -> Result<bool> {
548        let class_id = br.read_bits(class_info.bits)? as i32;
549        let _serial = br.read_bits(NUM_SERIAL_NUM_BITS as usize)?;
550        let _unknown = br.read_uvarint32()?;
551
552        let class_entry = class_info.by_id(class_id).ok_or_else(|| Error::Parse {
553            context: format!("unknown class_id {}", class_id),
554        })?;
555
556        let serializer =
557            serializers
558                .get(&class_entry.network_name)
559                .ok_or_else(|| Error::Parse {
560                    context: format!("no serializer for {}", class_entry.network_name),
561                })?;
562
563        // Check if this class is in our filter
564        if !class_filter.contains(class_entry.network_name.as_str()) {
565            // Skip this entity - just advance the bit reader
566            // But track its class_id so we can skip updates later
567            self.skipped_entity_classes.insert(index, class_id);
568            Entity::skip_update(br, serializer, field_decode_ctx, fp_buf)?;
569            return Ok(false);
570        }
571
572        // Full processing for filtered entities
573        let mut entity = Entity::new(index, class_id, class_entry.network_name.clone());
574        // Pre-size the field map to the class's field count: a create applies the
575        // baseline plus the create delta, setting many fields at once, so starting
576        // from an empty map otherwise rehashes repeatedly as it grows.
577        entity.fields.reserve(serializer.fields.len());
578
579        if let Some(baseline_data) = string_tables.instance_baselines.get(&class_id) {
580            let mut baseline_br = BitReader::new(baseline_data);
581            entity.apply_update(&mut baseline_br, serializer, field_decode_ctx, fp_buf)?;
582        }
583
584        entity.apply_update(br, serializer, field_decode_ctx, fp_buf)?;
585        self.entities.insert(index, entity);
586
587        Ok(true)
588    }
589
590    /// Returns `true` if this entity is tracked and was updated (vs. skipped).
591    #[allow(clippy::too_many_arguments)]
592    fn handle_update_filtered(
593        &mut self,
594        index: i32,
595        br: &mut BitReader,
596        class_info: &ClassInfo,
597        serializers: &SerializerContainer,
598        field_decode_ctx: &mut FieldDecodeContext,
599        _class_filter: &HashSet<&str>,
600        fp_buf: &mut Vec<FieldPath>,
601    ) -> Result<bool> {
602        // Check if we're tracking this entity
603        if let Some(entity) = self.entities.get_mut(&index) {
604            let serializer = serializers
605                .get(&entity.class_name)
606                .ok_or_else(|| Error::Parse {
607                    context: format!("no serializer for {}", entity.class_name),
608                })?;
609
610            entity.apply_update(br, serializer, field_decode_ctx, fp_buf)?;
611            return Ok(true);
612        }
613
614        // Entity is not tracked - check if we know its class from skipped creates
615        if let Some(&class_id) = self.skipped_entity_classes.get(&index) {
616            let class_entry = class_info.by_id(class_id).ok_or_else(|| Error::Parse {
617                context: format!("unknown class_id {}", class_id),
618            })?;
619
620            let serializer =
621                serializers
622                    .get(&class_entry.network_name)
623                    .ok_or_else(|| Error::Parse {
624                        context: format!("no serializer for {}", class_entry.network_name),
625                    })?;
626
627            // Skip this update
628            Entity::skip_update(br, serializer, field_decode_ctx, fp_buf)?;
629        }
630
631        // If we don't know about this entity at all, it was created before filtering started
632        // This shouldn't happen if we start filtering from the beginning
633        Ok(false)
634    }
635
636    /// Look up an entity by its slot index.
637    pub fn get(&self, index: i32) -> Option<&Entity> {
638        self.entities.get(&index)
639    }
640
641    /// Resolve a networked `CHandle` to the entity it refers to, if still active.
642    ///
643    /// Applies [`ENTITY_HANDLE_INDEX_MASK`] to recover the entity index, then
644    /// looks it up. This is the canonical way to follow a handle field such as
645    /// `m_hPawn`; decoding the mask by hand risks resolving the wrong entity.
646    pub fn get_by_handle(&self, handle: u32) -> Option<&Entity> {
647        self.get((handle & ENTITY_HANDLE_INDEX_MASK) as i32)
648    }
649
650    /// Iterate over all active entities as `(index, entity)` pairs.
651    pub fn iter(&self) -> impl Iterator<Item = (&i32, &Entity)> {
652        self.entities.iter()
653    }
654
655    /// Indices of tracked entities created or updated during the current tick.
656    ///
657    /// Lets change-only consumers (cooldowns, modifiers, …) process just the
658    /// entities a tick touched instead of rescanning every active entity. Valid
659    /// inside a `run_to_end*` per-tick callback; the parser clears it after each
660    /// callback returns.
661    ///
662    /// Caveats: an index may appear more than once if multiple packets in the
663    /// same tick updated the entity, and may refer to an entity that is no
664    /// longer active if it was deleted later in the same tick. Resolve each with
665    /// [`get`](Self::get) and skip `None`.
666    pub fn updated_indices(&self) -> &[i32] {
667        &self.updated
668    }
669
670    /// Clear the per-tick updated set. Called by the parser after each callback.
671    pub fn clear_updated(&mut self) {
672        self.updated.clear();
673    }
674
675    /// Number of currently active entities.
676    pub fn len(&self) -> usize {
677        self.entities.len()
678    }
679
680    pub fn is_empty(&self) -> bool {
681        self.entities.is_empty()
682    }
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688
689    #[test]
690    fn container_new_is_empty() {
691        let c = EntityContainer::new();
692        assert!(c.is_empty());
693        assert_eq!(c.len(), 0);
694        assert!(c.get(0).is_none());
695    }
696
697    #[test]
698    fn entity_fields_insert_and_get() {
699        let mut e = Entity::new(1, 10, "TestClass".to_string());
700        e.fields.insert(42, FieldValue::I32(100));
701        assert!(matches!(e.fields.get(&42), Some(FieldValue::I32(100))));
702    }
703
704    #[test]
705    fn container_insert_and_iter() {
706        let mut c = EntityContainer::new();
707        let e = Entity::new(5, 10, "Hero".to_string());
708        c.entities.insert(5, e);
709        assert_eq!(c.len(), 1);
710        assert!(!c.is_empty());
711        assert!(c.get(5).is_some());
712        assert_eq!(c.get(5).unwrap().class_name, "Hero");
713    }
714
715    #[test]
716    fn container_iter_yields_entries() {
717        let mut c = EntityContainer::new();
718        c.entities.insert(1, Entity::new(1, 1, "A".to_string()));
719        c.entities.insert(2, Entity::new(2, 2, "B".to_string()));
720        let keys: Vec<i32> = c.iter().map(|(&k, _)| k).collect();
721        assert_eq!(keys.len(), 2);
722    }
723
724    #[test]
725    fn entity_basic_fields() {
726        let e = Entity::new(7, 42, "NPC".to_string());
727        assert_eq!(e.index, 7);
728        assert_eq!(e.class_id, 42);
729        assert_eq!(e.class_name, "NPC");
730        assert_eq!(e.serial, 0);
731        assert!(e.fields.is_empty());
732    }
733}