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}
307
308impl EntityContainer {
309    pub fn new() -> Self {
310        Self::default()
311    }
312
313    /// Handle a CSVCMsg_PacketEntities message.
314    pub fn handle_packet_entities(
315        &mut self,
316        msg: CsvcMsgPacketEntities,
317        class_info: &ClassInfo,
318        serializers: &SerializerContainer,
319        string_tables: &StringTableContainer,
320        field_decode_ctx: &mut FieldDecodeContext,
321        fp_buf: &mut Vec<FieldPath>,
322    ) -> Result<()> {
323        let entity_data = msg.entity_data.unwrap_or_default();
324        let mut br = BitReader::new(&entity_data);
325
326        let mut entity_index: i32 = -1;
327
328        for _ in 0..msg.updated_entries.unwrap_or(0) {
329            entity_index += br.read_ubitvar()? as i32 + 1;
330
331            // Read delta header (2 bits)
332            let dh = br.read_bits(2)? as u8;
333
334            match dh {
335                DELTA_CREATE => {
336                    self.handle_create(
337                        entity_index,
338                        &mut br,
339                        class_info,
340                        serializers,
341                        string_tables,
342                        field_decode_ctx,
343                        fp_buf,
344                    )
345                    .map_err(|e| Error::Parse {
346                        context: format!("entity create #{}: {}", entity_index, e),
347                    })?;
348                }
349                DELTA_UPDATE => {
350                    self.handle_update(
351                        entity_index,
352                        &mut br,
353                        class_info,
354                        serializers,
355                        field_decode_ctx,
356                        fp_buf,
357                    )
358                    .map_err(|e| Error::Parse {
359                        context: format!(
360                            "entity update #{} (class: {:?}): {}",
361                            entity_index,
362                            self.entities.get(&entity_index).map(|e| &e.class_name),
363                            e
364                        ),
365                    })?;
366                }
367                DELTA_DELETE | DELTA_LEAVE => {
368                    self.entities.remove(&entity_index);
369                }
370                _ => {}
371            }
372        }
373
374        Ok(())
375    }
376
377    /// Handle a CSVCMsg_PacketEntities message, only tracking specified entity classes.
378    /// Entities not in the filter are parsed (to advance the bit reader) but not stored.
379    #[allow(clippy::too_many_arguments)]
380    pub fn handle_packet_entities_filtered(
381        &mut self,
382        msg: CsvcMsgPacketEntities,
383        class_info: &ClassInfo,
384        serializers: &SerializerContainer,
385        string_tables: &StringTableContainer,
386        field_decode_ctx: &mut FieldDecodeContext,
387        class_filter: &HashSet<&str>,
388        fp_buf: &mut Vec<FieldPath>,
389    ) -> Result<()> {
390        let entity_data = msg.entity_data.unwrap_or_default();
391        let mut br = BitReader::new(&entity_data);
392
393        let mut entity_index: i32 = -1;
394
395        for _ in 0..msg.updated_entries.unwrap_or(0) {
396            entity_index += br.read_ubitvar()? as i32 + 1;
397
398            // Read delta header (2 bits)
399            let dh = br.read_bits(2)? as u8;
400
401            match dh {
402                DELTA_CREATE => {
403                    self.handle_create_filtered(
404                        entity_index,
405                        &mut br,
406                        class_info,
407                        serializers,
408                        string_tables,
409                        field_decode_ctx,
410                        class_filter,
411                        fp_buf,
412                    )?;
413                }
414                DELTA_UPDATE => {
415                    self.handle_update_filtered(
416                        entity_index,
417                        &mut br,
418                        class_info,
419                        serializers,
420                        field_decode_ctx,
421                        class_filter,
422                        fp_buf,
423                    )?;
424                }
425                DELTA_DELETE | DELTA_LEAVE => {
426                    self.entities.remove(&entity_index);
427                    self.skipped_entity_classes.remove(&entity_index);
428                }
429                _ => {}
430            }
431        }
432
433        Ok(())
434    }
435
436    #[allow(clippy::too_many_arguments)]
437    fn handle_create(
438        &mut self,
439        index: i32,
440        br: &mut BitReader,
441        class_info: &ClassInfo,
442        serializers: &SerializerContainer,
443        string_tables: &StringTableContainer,
444        field_decode_ctx: &mut FieldDecodeContext,
445        fp_buf: &mut Vec<FieldPath>,
446    ) -> Result<()> {
447        let class_id = br.read_bits(class_info.bits)? as i32;
448        let _serial = br.read_bits(NUM_SERIAL_NUM_BITS as usize)?;
449        let _unknown = br.read_uvarint32()?;
450
451        let class_entry = class_info.by_id(class_id).ok_or_else(|| Error::Parse {
452            context: format!("unknown class_id {}", class_id),
453        })?;
454
455        let serializer =
456            serializers
457                .get(&class_entry.network_name)
458                .ok_or_else(|| Error::Parse {
459                    context: format!("no serializer for {}", class_entry.network_name),
460                })?;
461
462        let mut entity = Entity::new(index, class_id, class_entry.network_name.clone());
463
464        // Apply baseline from instancebaseline string table
465        if let Some(baseline_data) = string_tables.instance_baselines.get(&class_id) {
466            let mut baseline_br = BitReader::new(baseline_data);
467            entity
468                .apply_update(&mut baseline_br, serializer, field_decode_ctx, fp_buf)
469                .map_err(|err| Error::Parse {
470                    context: format!(
471                        "baseline for {} (class_id {}): {}",
472                        class_entry.network_name, class_id, err
473                    ),
474                })?;
475        }
476
477        // Apply create delta
478        entity
479            .apply_update(br, serializer, field_decode_ctx, fp_buf)
480            .map_err(|err| Error::Parse {
481                context: format!(
482                    "create delta for {} (class_id {}): {}",
483                    class_entry.network_name, class_id, err
484                ),
485            })?;
486        self.entities.insert(index, entity);
487
488        Ok(())
489    }
490
491    fn handle_update(
492        &mut self,
493        index: i32,
494        br: &mut BitReader,
495        _class_info: &ClassInfo,
496        serializers: &SerializerContainer,
497        field_decode_ctx: &mut FieldDecodeContext,
498        fp_buf: &mut Vec<FieldPath>,
499    ) -> Result<()> {
500        let entity = match self.entities.get_mut(&index) {
501            Some(e) => e,
502            None => {
503                return Err(Error::Parse {
504                    context: format!("tried to update non-existent entity #{}", index),
505                });
506            }
507        };
508
509        let serializer = serializers
510            .get(&entity.class_name)
511            .ok_or_else(|| Error::Parse {
512                context: format!("no serializer for {}", entity.class_name),
513            })?;
514
515        entity.apply_update(br, serializer, field_decode_ctx, fp_buf)?;
516        Ok(())
517    }
518
519    #[allow(clippy::too_many_arguments)]
520    fn handle_create_filtered(
521        &mut self,
522        index: i32,
523        br: &mut BitReader,
524        class_info: &ClassInfo,
525        serializers: &SerializerContainer,
526        string_tables: &StringTableContainer,
527        field_decode_ctx: &mut FieldDecodeContext,
528        class_filter: &HashSet<&str>,
529        fp_buf: &mut Vec<FieldPath>,
530    ) -> Result<()> {
531        let class_id = br.read_bits(class_info.bits)? as i32;
532        let _serial = br.read_bits(NUM_SERIAL_NUM_BITS as usize)?;
533        let _unknown = br.read_uvarint32()?;
534
535        let class_entry = class_info.by_id(class_id).ok_or_else(|| Error::Parse {
536            context: format!("unknown class_id {}", class_id),
537        })?;
538
539        let serializer =
540            serializers
541                .get(&class_entry.network_name)
542                .ok_or_else(|| Error::Parse {
543                    context: format!("no serializer for {}", class_entry.network_name),
544                })?;
545
546        // Check if this class is in our filter
547        if !class_filter.contains(class_entry.network_name.as_str()) {
548            // Skip this entity - just advance the bit reader
549            // But track its class_id so we can skip updates later
550            self.skipped_entity_classes.insert(index, class_id);
551            Entity::skip_update(br, serializer, field_decode_ctx, fp_buf)?;
552            return Ok(());
553        }
554
555        // Full processing for filtered entities
556        let mut entity = Entity::new(index, class_id, class_entry.network_name.clone());
557
558        if let Some(baseline_data) = string_tables.instance_baselines.get(&class_id) {
559            let mut baseline_br = BitReader::new(baseline_data);
560            entity.apply_update(&mut baseline_br, serializer, field_decode_ctx, fp_buf)?;
561        }
562
563        entity.apply_update(br, serializer, field_decode_ctx, fp_buf)?;
564        self.entities.insert(index, entity);
565
566        Ok(())
567    }
568
569    #[allow(clippy::too_many_arguments)]
570    fn handle_update_filtered(
571        &mut self,
572        index: i32,
573        br: &mut BitReader,
574        class_info: &ClassInfo,
575        serializers: &SerializerContainer,
576        field_decode_ctx: &mut FieldDecodeContext,
577        _class_filter: &HashSet<&str>,
578        fp_buf: &mut Vec<FieldPath>,
579    ) -> Result<()> {
580        // Check if we're tracking this entity
581        if let Some(entity) = self.entities.get_mut(&index) {
582            let serializer = serializers
583                .get(&entity.class_name)
584                .ok_or_else(|| Error::Parse {
585                    context: format!("no serializer for {}", entity.class_name),
586                })?;
587
588            entity.apply_update(br, serializer, field_decode_ctx, fp_buf)?;
589            return Ok(());
590        }
591
592        // Entity is not tracked - check if we know its class from skipped creates
593        if let Some(&class_id) = self.skipped_entity_classes.get(&index) {
594            let class_entry = class_info.by_id(class_id).ok_or_else(|| Error::Parse {
595                context: format!("unknown class_id {}", class_id),
596            })?;
597
598            let serializer =
599                serializers
600                    .get(&class_entry.network_name)
601                    .ok_or_else(|| Error::Parse {
602                        context: format!("no serializer for {}", class_entry.network_name),
603                    })?;
604
605            // Skip this update
606            Entity::skip_update(br, serializer, field_decode_ctx, fp_buf)?;
607        }
608
609        // If we don't know about this entity at all, it was created before filtering started
610        // This shouldn't happen if we start filtering from the beginning
611        Ok(())
612    }
613
614    /// Look up an entity by its slot index.
615    pub fn get(&self, index: i32) -> Option<&Entity> {
616        self.entities.get(&index)
617    }
618
619    /// Resolve a networked `CHandle` to the entity it refers to, if still active.
620    ///
621    /// Applies [`ENTITY_HANDLE_INDEX_MASK`] to recover the entity index, then
622    /// looks it up. This is the canonical way to follow a handle field such as
623    /// `m_hPawn`; decoding the mask by hand risks resolving the wrong entity.
624    pub fn get_by_handle(&self, handle: u32) -> Option<&Entity> {
625        self.get((handle & ENTITY_HANDLE_INDEX_MASK) as i32)
626    }
627
628    /// Iterate over all active entities as `(index, entity)` pairs.
629    pub fn iter(&self) -> impl Iterator<Item = (&i32, &Entity)> {
630        self.entities.iter()
631    }
632
633    /// Number of currently active entities.
634    pub fn len(&self) -> usize {
635        self.entities.len()
636    }
637
638    pub fn is_empty(&self) -> bool {
639        self.entities.is_empty()
640    }
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646
647    #[test]
648    fn container_new_is_empty() {
649        let c = EntityContainer::new();
650        assert!(c.is_empty());
651        assert_eq!(c.len(), 0);
652        assert!(c.get(0).is_none());
653    }
654
655    #[test]
656    fn entity_fields_insert_and_get() {
657        let mut e = Entity::new(1, 10, "TestClass".to_string());
658        e.fields.insert(42, FieldValue::I32(100));
659        assert!(matches!(e.fields.get(&42), Some(FieldValue::I32(100))));
660    }
661
662    #[test]
663    fn container_insert_and_iter() {
664        let mut c = EntityContainer::new();
665        let e = Entity::new(5, 10, "Hero".to_string());
666        c.entities.insert(5, e);
667        assert_eq!(c.len(), 1);
668        assert!(!c.is_empty());
669        assert!(c.get(5).is_some());
670        assert_eq!(c.get(5).unwrap().class_name, "Hero");
671    }
672
673    #[test]
674    fn container_iter_yields_entries() {
675        let mut c = EntityContainer::new();
676        c.entities.insert(1, Entity::new(1, 1, "A".to_string()));
677        c.entities.insert(2, Entity::new(2, 2, "B".to_string()));
678        let keys: Vec<i32> = c.iter().map(|(&k, _)| k).collect();
679        assert_eq!(keys.len(), 2);
680    }
681
682    #[test]
683    fn entity_basic_fields() {
684        let e = Entity::new(7, 42, "NPC".to_string());
685        assert_eq!(e.index, 7);
686        assert_eq!(e.class_id, 42);
687        assert_eq!(e.class_name, "NPC");
688        assert_eq!(e.serial, 0);
689        assert!(e.fields.is_empty());
690    }
691}