Skip to main content

flatland_protocol/
types.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6pub type EntityId = u64;
7pub type Tick = u64;
8pub type Seq = u32;
9pub type SessionId = u64;
10
11/// World position `(x, y, z, w, t)` — `t` reserved at 0.
12#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
13pub struct WorldCoord {
14    pub x: f32,
15    pub y: f32,
16    pub z: f32,
17    pub w: u32,
18    pub t: u32,
19}
20
21impl WorldCoord {
22    pub fn surface(x: f32, y: f32) -> Self {
23        Self {
24            x,
25            y,
26            z: 0.0,
27            w: 0,
28            t: 0,
29        }
30    }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
34pub struct Velocity2D {
35    pub vx: f32,
36    pub vy: f32,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
40pub struct Transform {
41    pub position: WorldCoord,
42    pub yaw: f32,
43    pub velocity: Velocity2D,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum LifeState {
49    Alive,
50    Dead,
51}
52
53impl Default for LifeState {
54    fn default() -> Self {
55        Self::Alive
56    }
57}
58
59/// Named slice of the in-game day (drives future weather / spawn tables).
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum TimeOfDayPhase {
63    Night,
64    Dawn,
65    Morning,
66    Midday,
67    Afternoon,
68    Evening,
69}
70
71impl TimeOfDayPhase {
72    pub fn label(self) -> &'static str {
73        match self {
74            Self::Night => "Night",
75            Self::Dawn => "Dawn",
76            Self::Morning => "Morning",
77            Self::Midday => "Midday",
78            Self::Afternoon => "Afternoon",
79            Self::Evening => "Evening",
80        }
81    }
82
83    pub fn from_name(name: &str) -> Self {
84        match name.to_ascii_lowercase().as_str() {
85            "dawn" => Self::Dawn,
86            "morning" => Self::Morning,
87            "midday" | "mid_day" | "noon" => Self::Midday,
88            "afternoon" => Self::Afternoon,
89            "evening" | "dusk" => Self::Evening,
90            _ => Self::Night,
91        }
92    }
93}
94
95/// Authoritative region clock (one in-game day = configurable real duration).
96#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
97pub struct WorldClock {
98    /// Days elapsed since region start (0-based).
99    pub day: u64,
100    pub hour: u8,
101    pub minute: u8,
102    pub phase: TimeOfDayPhase,
103}
104
105impl Default for WorldClock {
106    fn default() -> Self {
107        Self {
108            day: 0,
109            hour: 8,
110            minute: 0,
111            phase: TimeOfDayPhase::Morning,
112        }
113    }
114}
115
116impl WorldClock {
117    pub fn display_time(self) -> String {
118        format!("{:02}:{:02}", self.hour, self.minute)
119    }
120}
121
122/// Core primary stats — internal scale 1–1000; UI displays `value / 10` (1–100).
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124pub struct PrimaryAttributes {
125    pub strength: u16,
126    pub dexterity: u16,
127    pub intelligence: u16,
128    pub stamina: u16,
129    pub vitality: u16,
130    pub wisdom: u16,
131    pub charisma: u16,
132}
133
134impl Default for PrimaryAttributes {
135    fn default() -> Self {
136        Self {
137            strength: 500,
138            dexterity: 500,
139            intelligence: 500,
140            stamina: 500,
141            vitality: 500,
142            wisdom: 500,
143            charisma: 500,
144        }
145    }
146}
147
148impl PrimaryAttributes {
149    /// Map internal 1–1000 to player-facing 1–100.
150    pub fn display(value: u16) -> u16 {
151        (value / 10).clamp(1, 100)
152    }
153
154    /// Client-facing derived stat preview (mirrors sim tuning).
155    pub fn derived_preview(&self) -> DerivedPreview {
156        let str_d = Self::display(self.strength) as f32;
157        let dex_d = Self::display(self.dexterity) as f32;
158        let int_d = Self::display(self.intelligence) as f32;
159        let wis_d = Self::display(self.wisdom) as f32;
160        DerivedPreview {
161            attack_power: str_d * 1.2 + dex_d * 0.3,
162            spell_power: int_d * 1.1 + wis_d * 0.4,
163            evasion: dex_d * 0.8 + wis_d * 0.2,
164            carry_mass_max: str_d * 2.5,
165            sight_range_m: 12.0 + wis_d * 0.15 + dex_d * 0.05,
166            fov_deg: 120.0 + wis_d * 0.2,
167        }
168    }
169}
170
171/// Derived combat/survival preview for character sheet UI.
172#[derive(Debug, Clone, Copy, PartialEq)]
173pub struct DerivedPreview {
174    pub attack_power: f32,
175    pub spell_power: f32,
176    pub evasion: f32,
177    pub carry_mass_max: f32,
178    pub sight_range_m: f32,
179    pub fov_deg: f32,
180}
181
182/// Trained skill — internal 0–1000; UI uses tiers / display level.
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
184pub struct SkillProgress {
185    pub level: u16,
186    #[serde(default)]
187    pub last_trained_tick: u64,
188}
189
190impl Default for SkillProgress {
191    fn default() -> Self {
192        Self {
193            level: 0,
194            last_trained_tick: 0,
195        }
196    }
197}
198
199impl SkillProgress {
200    /// Mastery tier 0–10 (`plans/11` §10). Tier 0 = untrained; tier 1 begins at internal 100.
201    pub fn display_tier(&self) -> u16 {
202        (self.level / 100).min(10)
203    }
204}
205
206/// Core trained skills shipped at launch (`plans/11` §10).
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
208#[serde(default)]
209pub struct PlayerSkills {
210    pub logging: SkillProgress,
211    pub mining: SkillProgress,
212    pub evocation: SkillProgress,
213    pub swords: SkillProgress,
214    pub crafting: SkillProgress,
215    pub cartography: SkillProgress,
216}
217
218impl Default for PlayerSkills {
219    fn default() -> Self {
220        Self {
221            logging: SkillProgress::default(),
222            mining: SkillProgress::default(),
223            evocation: SkillProgress::default(),
224            swords: SkillProgress::default(),
225            crafting: SkillProgress::default(),
226            cartography: SkillProgress::default(),
227        }
228    }
229}
230
231/// Player health / resources (players only; omitted on other entities).
232#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
233pub struct PlayerVitals {
234    pub health: f32,
235    pub health_max: f32,
236    pub mana: f32,
237    pub mana_max: f32,
238    pub stamina: f32,
239    pub stamina_max: f32,
240    #[serde(default = "default_survival_pool_max")]
241    pub hunger: f32,
242    #[serde(default = "default_survival_pool_max")]
243    pub hunger_max: f32,
244    #[serde(default = "default_survival_pool_max")]
245    pub thirst: f32,
246    #[serde(default = "default_survival_pool_max")]
247    pub thirst_max: f32,
248    #[serde(default)]
249    pub coins: u32,
250    #[serde(default)]
251    pub deaths: u32,
252    #[serde(default)]
253    pub life_state: LifeState,
254}
255
256fn default_survival_pool_max() -> f32 {
257    100.0
258}
259
260impl Default for PlayerVitals {
261    fn default() -> Self {
262        Self::from_attributes(PrimaryAttributes::default())
263    }
264}
265
266impl PlayerVitals {
267    /// Build pool maximums from primary attributes (current pools filled to max).
268    ///
269    /// Uses **display** stats (internal ÷ 10) so pools scale intuitively with
270    /// progression, gear, and future enchant modifiers on attributes.
271    ///
272    /// Default attrs (all 500 → display 50): ~150 HP, ~100 stamina, ~145 mana.
273    pub fn from_attributes(attrs: PrimaryAttributes) -> Self {
274        let vit_d = PrimaryAttributes::display(attrs.vitality) as f32;
275        let int_d = PrimaryAttributes::display(attrs.intelligence) as f32;
276        let wis_d = PrimaryAttributes::display(attrs.wisdom) as f32;
277        let sta_d = PrimaryAttributes::display(attrs.stamina) as f32;
278
279        let health_max = 50.0 + vit_d * 2.0;
280        let stamina_max = 30.0 + sta_d * 1.4;
281        let mana_max = 25.0 + int_d * 1.1 + wis_d * 1.3;
282        let hunger_max = 100.0;
283        let thirst_max = 100.0;
284        Self {
285            health: health_max,
286            health_max,
287            mana: mana_max,
288            mana_max,
289            stamina: stamina_max,
290            stamina_max,
291            hunger: hunger_max,
292            hunger_max,
293            thirst: thirst_max,
294            thirst_max,
295            coins: 0,
296            deaths: 0,
297            life_state: LifeState::Alive,
298        }
299    }
300
301    /// Legacy pool maxima (pre-2026-07 pool rebalance) for migration scaling.
302    pub fn legacy_maxima(attrs: PrimaryAttributes) -> (f32, f32, f32) {
303        (
304            attrs.vitality as f32 / 5.0,
305            attrs.stamina as f32 / 5.0,
306            (attrs.intelligence as f32 + attrs.wisdom as f32) / 20.0,
307        )
308    }
309}
310
311/// Durable pool snapshot for Postgres (max values recomputed from attributes on load).
312#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
313#[serde(default)]
314pub struct StoredVitalsState {
315    pub health: f32,
316    pub mana: f32,
317    pub stamina: f32,
318    pub hunger: f32,
319    pub thirst: f32,
320    pub coins: u32,
321    pub deaths: u32,
322    pub life_state: LifeState,
323}
324
325impl StoredVitalsState {
326    pub fn from_live(v: &PlayerVitals) -> Self {
327        Self {
328            health: v.health,
329            mana: v.mana,
330            stamina: v.stamina,
331            hunger: v.hunger,
332            thirst: v.thirst,
333            coins: v.coins,
334            deaths: v.deaths,
335            life_state: v.life_state,
336        }
337    }
338
339    /// Empty `{}` JSON from a new character row — not a real gameplay snapshot.
340    pub fn is_pristine(&self) -> bool {
341        self.health == 0.0
342            && self.mana == 0.0
343            && self.stamina == 0.0
344            && self.hunger == 0.0
345            && self.thirst == 0.0
346            && self.coins == 0
347            && self.deaths == 0
348            && self.life_state == LifeState::Alive
349    }
350
351    pub fn apply_to(self, attrs: PrimaryAttributes) -> PlayerVitals {
352        if self.is_pristine() {
353            return PlayerVitals::from_attributes(attrs);
354        }
355        let fresh = PlayerVitals::from_attributes(attrs);
356        let (legacy_hp, legacy_sta, legacy_mana) = PlayerVitals::legacy_maxima(attrs);
357
358        let scale = |current: f32, legacy_max: f32, new_max: f32| {
359            if legacy_max > 0.0
360                && new_max > legacy_max * 1.05
361                && current >= legacy_max * 0.95
362            {
363                let ratio = (current / legacy_max).clamp(0.0, 1.0);
364                (new_max * ratio).min(new_max)
365            } else {
366                current.min(new_max)
367            }
368        };
369
370        let mut v = fresh;
371        v.health = scale(self.health, legacy_hp, fresh.health_max);
372        v.mana = scale(self.mana, legacy_mana, fresh.mana_max);
373        v.stamina = scale(self.stamina, legacy_sta, fresh.stamina_max);
374        v.hunger = self.hunger.min(v.hunger_max);
375        v.thirst = self.thirst.min(v.thirst_max);
376        v.coins = self.coins;
377        v.deaths = self.deaths;
378        v.life_state = self.life_state;
379        v
380    }
381}
382
383impl Default for StoredVitalsState {
384    fn default() -> Self {
385        Self::from_live(&PlayerVitals::default())
386    }
387}
388
389/// Saved ability rotation preset (`plans/26` §C3).
390#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
391pub struct RotationPreset {
392    pub id: String,
393    pub label: String,
394    #[serde(default)]
395    pub abilities: Vec<String>,
396}
397
398impl RotationPreset {
399    pub fn melee_default(ability_id: impl Into<String>) -> Self {
400        let id = ability_id.into();
401        Self {
402            id: "melee".into(),
403            label: "Melee".into(),
404            abilities: vec![id],
405        }
406    }
407}
408
409/// Durable combat state (`plans/12` §4.1) — target by wildlife instance id, not entity id.
410#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
411pub struct StoredTargetSlot {
412    pub instance_id: Option<String>,
413    #[serde(default)]
414    pub preset_id: Option<String>,
415    #[serde(default)]
416    pub rotation_index: u32,
417    #[serde(default)]
418    pub auto_enabled: bool,
419}
420
421#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
422#[serde(default)]
423pub struct StoredCombatProfile {
424    /// Wildlife `instance_id` (e.g. `meadow-rabbits-0`), stable across worker restarts.
425    pub combat_target_instance_id: Option<String>,
426    pub in_combat: bool,
427    pub last_combat_tick: u64,
428    pub last_attack_tick: u64,
429    pub cooldowns_until_tick: BTreeMap<String, u64>,
430    #[serde(default = "default_auto_attack")]
431    pub auto_attack_enabled: bool,
432    /// Equipped mainhand weapon template id (`plans/26` §C2b).
433    #[serde(default)]
434    pub mainhand_template_id: Option<String>,
435    /// Equipped body-slot items (backpack, belt, future armor), nested contents included
436    /// (e.g. pouches clipped onto a worn belt). At most one entry per `BodySlot`.
437    #[serde(default)]
438    pub worn: Vec<(BodySlot, ItemStack)>,
439    /// Player-authored rotation library (`plans/26` §C3).
440    #[serde(default)]
441    pub rotation_presets: Vec<RotationPreset>,
442    /// Per-target slot state (T1/T2).
443    #[serde(default)]
444    pub target_slots: Vec<StoredTargetSlot>,
445    /// Discovered craft recipe ids (`plans/09` §C2 hybrid discovery).
446    #[serde(default)]
447    pub known_blueprint_ids: Vec<String>,
448}
449
450fn default_auto_attack() -> bool {
451    true
452}
453
454impl Default for StoredCombatProfile {
455    fn default() -> Self {
456        Self {
457            combat_target_instance_id: None,
458            in_combat: false,
459            last_combat_tick: 0,
460            last_attack_tick: 0,
461            cooldowns_until_tick: BTreeMap::new(),
462            auto_attack_enabled: true,
463            mainhand_template_id: None,
464            worn: Vec::new(),
465            rotation_presets: Vec::new(),
466            target_slots: Vec::new(),
467            known_blueprint_ids: Vec::new(),
468        }
469    }
470}
471
472#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
473pub struct EntityState {
474    pub id: EntityId,
475    pub transform: Transform,
476    /// Character / display name (first letter used as map glyph for other players).
477    #[serde(default)]
478    pub label: String,
479    #[serde(default)]
480    pub vitals: Option<PlayerVitals>,
481    /// Primary attributes (local player / inspect).
482    #[serde(default)]
483    pub attributes: Option<PrimaryAttributes>,
484    #[serde(default)]
485    pub skills: Option<PlayerSkills>,
486    /// When inside a building interior (players only).
487    #[serde(default)]
488    pub inside_building: Option<String>,
489}
490
491/// Nearby voice chat vs magical long-range (requires whisper-stone item).
492#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
493#[serde(rename_all = "snake_case")]
494pub enum ChatChannel {
495    Nearby,
496    WhisperStone,
497}
498
499#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
500pub struct ChatMessage {
501    pub channel: ChatChannel,
502    pub from_entity: EntityId,
503    pub from_name: String,
504    pub text: String,
505    pub tick: Tick,
506}
507
508/// Client → server gameplay input (reliable, sequenced per entity).
509#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
510pub enum Intent {
511    Move {
512        entity_id: EntityId,
513        forward: f32,
514        strafe: f32,
515        /// Climb (+) or descend (−) on z axis (m/s intent).
516        #[serde(default)]
517        vertical: f32,
518        /// Sprint multiplier when stamina allows.
519        #[serde(default)]
520        sprint: bool,
521        seq: Seq,
522    },
523    Stop {
524        entity_id: EntityId,
525        seq: Seq,
526    },
527    Harvest {
528        entity_id: EntityId,
529        node_id: String,
530        seq: Seq,
531    },
532    Use {
533        entity_id: EntityId,
534        template_id: String,
535        seq: Seq,
536    },
537    Say {
538        entity_id: EntityId,
539        channel: ChatChannel,
540        text: String,
541        seq: Seq,
542    },
543    /// Start a blueprint craft (timed, like harvest).
544    Craft {
545        entity_id: EntityId,
546        blueprint_id: String,
547        /// Batches to run back-to-back; `None` crafts as many as materials/stamina allow.
548        #[serde(default)]
549        count: Option<u32>,
550        seq: Seq,
551    },
552    /// Door, NPC, enter/exit building.
553    Interact {
554        entity_id: EntityId,
555        target_id: String,
556        seq: Seq,
557    },
558    /// Buy from an NPC shop offer (`ShopOpened` catalog).
559    ShopBuy {
560        entity_id: EntityId,
561        npc_id: String,
562        offer_id: String,
563        #[serde(default = "default_one")]
564        quantity: u32,
565        seq: Seq,
566    },
567    /// Sell inventory to an NPC (`ShopOpened` buy list).
568    ShopSell {
569        entity_id: EntityId,
570        npc_id: String,
571        template_id: String,
572        #[serde(default = "default_one")]
573        quantity: u32,
574        seq: Seq,
575    },
576    /// Dev / test: apply damage to self (co-op testing).
577    TestDamage {
578        entity_id: EntityId,
579        amount: f32,
580        seq: Seq,
581    },
582    /// Slot-1 combat target (`plans/12` alias).
583    SetTarget {
584        entity_id: EntityId,
585        target_id: EntityId,
586        seq: Seq,
587    },
588    /// Target slot assignment (`plans/12` §5.3). Slot 1 aliases `SetTarget`.
589    SetTargetSlot {
590        entity_id: EntityId,
591        slot_index: u8,
592        target_id: EntityId,
593        seq: Seq,
594    },
595    ClearTarget {
596        entity_id: EntityId,
597        seq: Seq,
598    },
599    ClearTargetSlot {
600        entity_id: EntityId,
601        slot_index: u8,
602        seq: Seq,
603    },
604    /// Toggle per-slot auto-attack (`plans/12` §4.2).
605    SetAutoAttack {
606        entity_id: EntityId,
607        slot_index: u8,
608        enabled: bool,
609        seq: Seq,
610    },
611    /// Melee attack — uses `target_id` or the player's current target.
612    Attack {
613        entity_id: EntityId,
614        #[serde(default)]
615        target_id: Option<EntityId>,
616        #[serde(default)]
617        weapon_slot: Option<u32>,
618        seq: Seq,
619    },
620    /// Pick up a ground loot pile (nearest in range when `drop_id` omitted).
621    Pickup {
622        entity_id: EntityId,
623        #[serde(default)]
624        drop_id: Option<String>,
625        seq: Seq,
626    },
627    /// Cast a spell or use a non-weapon ability template (`plans/24` §4.3b).
628    Cast {
629        entity_id: EntityId,
630        ability_id: String,
631        target_id: EntityId,
632        seq: Seq,
633    },
634    /// Bind an ability to a target slot action bar (`plans/12` §4.2).
635    BindActionSlot {
636        entity_id: EntityId,
637        slot_index: u8,
638        ability_id: String,
639        #[serde(default = "default_auto_attack")]
640        auto_enabled: bool,
641        seq: Seq,
642    },
643    /// Fire a bound consumable or ability from a slot (`plans/24` §4.3c).
644    UseActionSlot {
645        entity_id: EntityId,
646        slot_index: u8,
647        seq: Seq,
648    },
649    /// Dodge — brief i-frames, stamina cost (`plans/24` §4.3c).
650    Dodge {
651        entity_id: EntityId,
652        seq: Seq,
653    },
654    /// Lunge — burst forward in movement/facing direction, higher stamina cost.
655    Lunge {
656        entity_id: EntityId,
657        /// Last movement forward axis when idle (−1..1).
658        #[serde(default)]
659        forward: f32,
660        /// Last movement strafe axis when idle (−1..1).
661        #[serde(default)]
662        strafe: f32,
663        seq: Seq,
664    },
665    /// Block — frontal mitigation while held (`plans/24` §4.3c).
666    Block {
667        entity_id: EntityId,
668        #[serde(default = "default_block_enabled")]
669        enabled: bool,
670        seq: Seq,
671    },
672    /// Equip or clear mainhand weapon (`plans/26` §C2b). `None` unequips.
673    EquipMainhand {
674        entity_id: EntityId,
675        #[serde(default)]
676        template_id: Option<String>,
677        seq: Seq,
678    },
679    /// Equip a wearable (container, belt, or armor piece) to a body slot, or clear the
680    /// slot when `instance_id` is None.
681    EquipWorn {
682        entity_id: EntityId,
683        slot: BodySlot,
684        #[serde(default)]
685        instance_id: Option<Uuid>,
686        seq: Seq,
687    },
688    /// Move an item instance between root / worn / placed container inventories.
689    MoveItem {
690        entity_id: EntityId,
691        item_instance_id: Uuid,
692        from: InventoryLocation,
693        to: InventoryLocation,
694        /// When moving into a container location, nest under this parent instance (None = container root).
695        #[serde(default)]
696        to_parent_instance_id: Option<Uuid>,
697        /// Units to move; `None` moves the whole stack. Server clamps to volume/carry limits.
698        #[serde(default)]
699        quantity: Option<u32>,
700        seq: Seq,
701    },
702    /// Place a placeable container from inventory onto the ground at the player's feet.
703    PlaceContainer {
704        entity_id: EntityId,
705        item_instance_id: Uuid,
706        seq: Seq,
707    },
708    /// Pick up a placed container (with contents) into inventory.
709    PickupContainer {
710        entity_id: EntityId,
711        container_id: String,
712        seq: Seq,
713    },
714    /// Lock or unlock a worn or placed container (requires matching key when locking/unlocking).
715    SetContainerLocked {
716        entity_id: EntityId,
717        /// Worn slot or placed container id encoded as location.
718        location: InventoryLocation,
719        locked: bool,
720        seq: Seq,
721    },
722    /// Drop a non-container item on the ground at the player's feet.
723    DropItem {
724        entity_id: EntityId,
725        item_instance_id: Uuid,
726        from: InventoryLocation,
727        seq: Seq,
728    },
729    /// Permanently destroy an item stack (not recoverable; no ground drop).
730    DestroyItem {
731        entity_id: EntityId,
732        item_instance_id: Uuid,
733        from: InventoryLocation,
734        /// Units to destroy; `None` destroys the whole stack.
735        #[serde(default)]
736        quantity: Option<u32>,
737        seq: Seq,
738    },
739    /// Set a custom display name on a container instance (chest, pouch, backpack).
740    RenameContainer {
741        entity_id: EntityId,
742        item_instance_id: Uuid,
743        location: InventoryLocation,
744        name: String,
745        seq: Seq,
746    },
747    /// Create or update a rotation preset in the player's library.
748    UpsertRotationPreset {
749        entity_id: EntityId,
750        preset: RotationPreset,
751        seq: Seq,
752    },
753    /// Remove a rotation preset from the library.
754    DeleteRotationPreset {
755        entity_id: EntityId,
756        preset_id: String,
757        seq: Seq,
758    },
759    /// Assign a library preset to target slot T1/T2.
760    AssignSlotPreset {
761        entity_id: EntityId,
762        slot_index: u8,
763        preset_id: String,
764        seq: Seq,
765    },
766    /// Fire the next ready ability in a slot's rotation (manual step).
767    AdvanceRotation {
768        entity_id: EntityId,
769        slot_index: u8,
770        seq: Seq,
771    },
772}
773
774fn default_block_enabled() -> bool {
775    true
776}
777
778/// Observer combat HUD (local player only).
779#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
780pub struct CombatTargetHud {
781    pub entity_id: EntityId,
782    #[serde(default)]
783    pub label: String,
784    #[serde(default)]
785    pub level: u32,
786    pub health: f32,
787    pub health_max: f32,
788    #[serde(default)]
789    pub life_state: LifeState,
790    #[serde(default)]
791    pub distance_m: f32,
792}
793
794/// Active spell cast channel progress.
795#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
796pub struct CastProgressHud {
797    #[serde(default)]
798    pub ability_id: String,
799    #[serde(default)]
800    pub ability_label: String,
801    #[serde(default)]
802    pub ticks_remaining: u64,
803    #[serde(default)]
804    pub ticks_total: u64,
805}
806
807/// Cooldown state for a combat ability shown on the action bar.
808#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
809pub struct AbilityCooldownHud {
810    #[serde(default)]
811    pub ability_id: String,
812    #[serde(default)]
813    pub label: String,
814    #[serde(default)]
815    pub cd_ticks: u64,
816    #[serde(default)]
817    pub cd_total_ticks: u64,
818}
819
820/// One combat target slot in the observer HUD.
821#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
822pub struct CombatSlotHud {
823    pub slot_index: u8,
824    #[serde(default)]
825    pub target_entity_id: Option<EntityId>,
826    #[serde(default)]
827    pub target_label: Option<String>,
828    #[serde(default)]
829    pub target: Option<CombatTargetHud>,
830    #[serde(default)]
831    pub preset_id: Option<String>,
832    #[serde(default)]
833    pub preset_label: Option<String>,
834    #[serde(default)]
835    pub rotation: Vec<String>,
836    #[serde(default)]
837    pub rotation_index: u32,
838    #[serde(default)]
839    pub next_ability_id: Option<String>,
840    #[serde(default)]
841    pub auto_enabled: bool,
842}
843
844/// Observer combat HUD (local player only).
845#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
846pub struct CombatHud {
847    pub in_combat: bool,
848    /// T1 auto rotation (legacy field mirrors slot 1).
849    pub auto_attack: bool,
850    pub has_los: bool,
851    pub attack_cd_ticks: u64,
852    #[serde(default)]
853    pub ability_id: String,
854    #[serde(default)]
855    pub target_entity_id: Option<EntityId>,
856    #[serde(default)]
857    pub target_label: Option<String>,
858    #[serde(default)]
859    pub max_target_slots: u8,
860    #[serde(default)]
861    pub slots: Vec<CombatSlotHud>,
862    #[serde(default)]
863    pub rotation_presets: Vec<RotationPreset>,
864    #[serde(default)]
865    pub gcd_ticks: u64,
866    #[serde(default)]
867    pub mainhand_template_id: Option<String>,
868    #[serde(default)]
869    pub mainhand_label: Option<String>,
870    /// Equipped body-slot items (backpack, belt w/ clipped pouches, future armor).
871    #[serde(default)]
872    pub worn: Vec<(BodySlot, ItemStack)>,
873    #[serde(default)]
874    pub carry_mass: f32,
875    #[serde(default)]
876    pub carry_mass_max: f32,
877    #[serde(default)]
878    pub encumbrance: EncumbranceState,
879    #[serde(default)]
880    pub target: Option<CombatTargetHud>,
881    #[serde(default)]
882    pub cast: Option<CastProgressHud>,
883    #[serde(default)]
884    pub ability_cooldowns: Vec<AbilityCooldownHud>,
885    #[serde(default)]
886    pub blocking_active: bool,
887}
888
889/// Server → client AOI-filtered entity updates for one sim tick.
890#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
891pub struct TickDelta {
892    pub tick: Tick,
893    pub entities: Vec<EntityState>,
894    #[serde(default)]
895    pub resource_nodes: Vec<ResourceNodeView>,
896    #[serde(default)]
897    pub buildings: Vec<BuildingView>,
898    #[serde(default)]
899    pub doors: Vec<DoorView>,
900    #[serde(default)]
901    pub npcs: Vec<NpcView>,
902    /// Observer inventory stacks (template → qty).
903    #[serde(default)]
904    pub inventory: Vec<ItemStack>,
905    #[serde(default)]
906    pub blueprints: Vec<BlueprintView>,
907    #[serde(default)]
908    pub world_clock: WorldClock,
909    #[serde(default)]
910    pub ground_drops: Vec<GroundDropView>,
911    #[serde(default)]
912    pub placed_containers: Vec<PlacedContainerView>,
913    #[serde(default)]
914    pub combat: Option<CombatHud>,
915}
916
917/// Loot pile on the ground (AOI-filtered).
918#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
919pub struct GroundDropView {
920    pub id: String,
921    pub template_id: String,
922    pub quantity: u32,
923    pub x: f32,
924    pub y: f32,
925    pub z: f32,
926}
927
928/// Full state on region enter or reconnect.
929#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
930pub struct Snapshot {
931    pub tick: Tick,
932    pub chunk_rev: u64,
933    /// Bumped when blueprints, catalog, segment, or settings reload.
934    #[serde(default)]
935    pub content_rev: u64,
936    pub entities: Vec<EntityState>,
937    #[serde(default)]
938    pub resource_nodes: Vec<ResourceNodeView>,
939    /// Segment play area width in meters (for HUD).
940    #[serde(default)]
941    pub world_width_m: f32,
942    #[serde(default)]
943    pub world_height_m: f32,
944    #[serde(default)]
945    pub buildings: Vec<BuildingView>,
946    #[serde(default)]
947    pub doors: Vec<DoorView>,
948    #[serde(default)]
949    pub npcs: Vec<NpcView>,
950    #[serde(default)]
951    pub inventory: Vec<ItemStack>,
952    #[serde(default)]
953    pub blueprints: Vec<BlueprintView>,
954    #[serde(default)]
955    pub world_clock: WorldClock,
956    #[serde(default)]
957    pub terrain_zones: Vec<TerrainZoneView>,
958    #[serde(default)]
959    pub ground_drops: Vec<GroundDropView>,
960    #[serde(default)]
961    pub placed_containers: Vec<PlacedContainerView>,
962    #[serde(default)]
963    pub combat: Option<CombatHud>,
964}
965
966/// Harvestable node visible to clients.
967#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
968pub struct ResourceNodeView {
969    pub id: String,
970    pub label: String,
971    pub x: f32,
972    pub y: f32,
973    pub z: f32,
974    pub item_template: String,
975    #[serde(default = "default_node_state")]
976    pub state: ResourceNodeState,
977    /// When true, players cannot walk through this node while available/harvesting.
978    #[serde(default = "default_blocking_view")]
979    pub blocking: bool,
980    /// Collision radius for pathfinding (meters).
981    #[serde(default = "default_blocking_radius_view")]
982    pub blocking_radius_m: f32,
983}
984
985fn default_blocking_radius_view() -> f32 {
986    0.8
987}
988
989fn default_blocking_view() -> bool {
990    true
991}
992
993#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
994#[serde(rename_all = "snake_case")]
995pub enum ResourceNodeState {
996    Available,
997    Harvesting,
998    Cooldown,
999}
1000
1001fn default_node_state() -> ResourceNodeState {
1002    ResourceNodeState::Available
1003}
1004
1005#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1006pub struct ItemStack {
1007    pub template_id: String,
1008    pub quantity: u32,
1009    /// Stable instance id — preserved across checkpoint/sync when set.
1010    #[serde(default)]
1011    pub item_instance_id: Option<Uuid>,
1012    /// Per-instance metadata (stat rolls, soul-bind, lock ids, etc.).
1013    #[serde(default)]
1014    pub props: BTreeMap<String, String>,
1015    /// Nested contents when this stack is a container instance.
1016    #[serde(default)]
1017    pub contents: Vec<ItemStack>,
1018    /// From item catalog when sent on wire (display only).
1019    #[serde(default)]
1020    pub display_name: Option<String>,
1021    /// From item catalog when sent on wire (`weapon`, `consumable`, …).
1022    #[serde(default)]
1023    pub category: Option<String>,
1024    /// Per-unit mass in kg from catalog (display / move UX).
1025    #[serde(default)]
1026    pub base_mass: Option<f32>,
1027    /// Per-unit volume from catalog (display / move UX).
1028    #[serde(default)]
1029    pub base_volume: Option<f32>,
1030    /// Container capacity when this stack is a container template.
1031    #[serde(default)]
1032    pub capacity_volume: Option<f32>,
1033    /// Whether the template stacks in inventory (from catalog).
1034    #[serde(default)]
1035    pub stackable: Option<bool>,
1036}
1037
1038impl ItemStack {
1039    pub fn simple(template_id: impl Into<String>, quantity: u32) -> Self {
1040        Self {
1041            template_id: template_id.into(),
1042            quantity,
1043            item_instance_id: None,
1044            props: BTreeMap::new(),
1045            contents: Vec::new(),
1046            display_name: None,
1047            category: None,
1048            base_mass: None,
1049            base_volume: None,
1050            capacity_volume: None,
1051            stackable: None,
1052        }
1053    }
1054}
1055
1056/// Carry encumbrance band (`plans/08` §3.2).
1057#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1058#[serde(rename_all = "snake_case")]
1059pub enum EncumbranceState {
1060    #[default]
1061    Light,
1062    Heavy,
1063    Over,
1064}
1065
1066/// Body region a wearable item occupies — at most one item equipped per slot.
1067/// `Head`/`Body`/`Arms`/`Legs`/`Feet` are reserved for future armor (`plans/11`); only
1068/// `Back` (backpack) and `Waist` (belt) have equippable items today (`plans/08` §4.1).
1069#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)]
1070#[serde(rename_all = "snake_case")]
1071pub enum BodySlot {
1072    Head,
1073    Body,
1074    Arms,
1075    Legs,
1076    Feet,
1077    Back,
1078    Waist,
1079}
1080
1081/// Where an item lives for `MoveItem` / open-container UX.
1082#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1083#[serde(rename_all = "snake_case")]
1084pub enum InventoryLocation {
1085    /// Loose on-person inventory (not inside a worn/placed container).
1086    Root,
1087    /// Inside a worn item (backpack contents, or a pouch clipped onto a worn belt).
1088    Worn {
1089        slot: BodySlot,
1090    },
1091    /// Inside a world-placed container.
1092    Placed {
1093        container_id: String,
1094    },
1095}
1096
1097/// AOI view of a placeable chest on the map.
1098#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1099pub struct PlacedContainerView {
1100    pub id: String,
1101    pub template_id: String,
1102    pub display_name: String,
1103    pub x: f32,
1104    pub y: f32,
1105    pub z: f32,
1106    pub locked: bool,
1107    /// Observer can open (unlocked, or holds matching key).
1108    #[serde(default)]
1109    pub accessible: bool,
1110    #[serde(default)]
1111    pub owner_character_id: Option<Uuid>,
1112    /// Nested contents when `accessible` (empty when locked without key).
1113    #[serde(default)]
1114    pub contents: Vec<ItemStack>,
1115    /// Lock id for matching `container_key` props (`opens_lock_id`).
1116    #[serde(default)]
1117    pub lock_id: Option<String>,
1118    /// Internal storage capacity (liters) from item catalog.
1119    #[serde(default)]
1120    pub capacity_volume: Option<f32>,
1121    /// Container instance id (for MoveItem parent targeting).
1122    #[serde(default)]
1123    pub item_instance_id: Option<Uuid>,
1124}
1125
1126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1127pub struct BlueprintIngredientView {
1128    pub template_id: String,
1129    pub quantity: u32,
1130    /// Always serialize — `true` is not bool::default() so postcard keeps it; explicit for clarity.
1131    pub consumed: bool,
1132}
1133
1134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1135pub struct ToolRequirementView {
1136    pub item: String,
1137    /// Always serialize — postcard omits `false` by default, which breaks roundtrip without explicit value.
1138    pub consumed: bool,
1139}
1140
1141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1142pub struct SkillRequirementView {
1143    pub skill: String,
1144    pub level: u32,
1145}
1146
1147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1148pub struct BlueprintView {
1149    pub id: String,
1150    pub label: String,
1151    pub output: String,
1152    pub output_qty: u32,
1153    pub craft_ticks: u32,
1154    pub inputs: Vec<BlueprintIngredientView>,
1155    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
1156    #[serde(default)]
1157    pub station: Option<String>,
1158    #[serde(default)]
1159    pub category: Option<String>,
1160    #[serde(default)]
1161    pub required_tools: Vec<ToolRequirementView>,
1162    #[serde(default)]
1163    pub skill: Option<SkillRequirementView>,
1164    #[serde(default)]
1165    pub failure_chance: f32,
1166}
1167
1168/// Terrain overlay from segment YAML (`terrain_zones`).
1169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1170#[serde(rename_all = "snake_case")]
1171pub enum TerrainKindView {
1172    #[default]
1173    Grass,
1174    Hill,
1175    Bog,
1176    ShallowWater,
1177    DeepWater,
1178}
1179
1180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1181pub struct TerrainZoneView {
1182    pub id: String,
1183    pub x0: f32,
1184    pub y0: f32,
1185    pub x1: f32,
1186    pub y1: f32,
1187    #[serde(default)]
1188    pub kind: TerrainKindView,
1189}
1190
1191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1192pub struct BuildingView {
1193    pub id: String,
1194    pub label: String,
1195    pub x: f32,
1196    pub y: f32,
1197    pub width_m: f32,
1198    pub depth_m: f32,
1199    /// Interior map origin (for wall rendering when player is inside).
1200    #[serde(default)]
1201    pub interior_origin_x: f32,
1202    #[serde(default)]
1203    pub interior_origin_y: f32,
1204    #[serde(default)]
1205    pub tags: Vec<String>,
1206}
1207
1208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1209pub struct DoorView {
1210    pub id: String,
1211    pub building_id: String,
1212    pub x: f32,
1213    pub y: f32,
1214    #[serde(default)]
1215    pub open: bool,
1216    #[serde(default)]
1217    pub is_exit: bool,
1218}
1219
1220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1221pub struct NpcView {
1222    pub id: String,
1223    pub label: String,
1224    pub role: String,
1225    pub x: f32,
1226    pub y: f32,
1227    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
1228    #[serde(default)]
1229    pub building_id: Option<String>,
1230    /// Authoritative sim entity for wildlife / combat targets.
1231    #[serde(default)]
1232    pub entity_id: Option<EntityId>,
1233    #[serde(default)]
1234    pub life_state: Option<LifeState>,
1235    #[serde(default)]
1236    pub hp_pct: Option<f32>,
1237}
1238
1239#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1240pub struct UseResult {
1241    pub template_id: String,
1242    pub hunger_restored: f32,
1243    pub thirst_restored: f32,
1244}
1245
1246#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1247pub struct CraftResult {
1248    pub blueprint_id: String,
1249    pub outputs: Vec<ItemStack>,
1250    pub consumed: Vec<ItemStack>,
1251    /// 1-based index within the submitted batch (1 when not batching).
1252    #[serde(default = "default_one")]
1253    pub batch_index: u32,
1254    /// Total crafts requested in this batch (1 when not batching).
1255    #[serde(default = "default_one")]
1256    pub batch_total: u32,
1257}
1258
1259fn default_one() -> u32 {
1260    1
1261}
1262
1263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1264pub struct DeathNotice {
1265    pub entity_id: EntityId,
1266    pub respawn_x: f32,
1267    pub respawn_y: f32,
1268    pub message: String,
1269}
1270
1271#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1272pub struct InteractionNotice {
1273    pub target_id: String,
1274    pub message: String,
1275    #[serde(default)]
1276    pub coins_delta: i32,
1277    #[serde(default)]
1278    pub inventory_delta: Vec<ItemStack>,
1279}
1280
1281#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1282#[serde(rename_all = "snake_case")]
1283pub enum ShopOfferKind {
1284    Item,
1285    Blueprint,
1286}
1287
1288#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1289pub struct ShopOffer {
1290    pub offer_id: String,
1291    pub kind: ShopOfferKind,
1292    pub label: String,
1293    #[serde(default)]
1294    pub template_id: Option<String>,
1295    #[serde(default)]
1296    pub blueprint_id: Option<String>,
1297    pub price_copper: u32,
1298    #[serde(default)]
1299    pub affordable: bool,
1300    #[serde(default)]
1301    pub already_owned: bool,
1302}
1303
1304#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1305pub struct ShopBuyLine {
1306    pub template_id: String,
1307    pub label: String,
1308    pub quantity: u32,
1309    pub price_copper: u32,
1310}
1311
1312#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1313pub struct ShopCatalog {
1314    pub npc_id: String,
1315    pub npc_label: String,
1316    #[serde(default)]
1317    pub sells: Vec<ShopOffer>,
1318    #[serde(default)]
1319    pub buys: Vec<ShopBuyLine>,
1320}
1321
1322#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1323pub struct HarvestResult {
1324    pub node_id: String,
1325    /// Stack quantity granted (not one-node-one-instance).
1326    pub quantity: u32,
1327    pub item_template: String,
1328    /// Optional DB row id when persisted to control plane.
1329    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
1330    #[serde(default)]
1331    pub item_instance_id: Option<Uuid>,
1332}
1333
1334/// Every on-wire payload is wrapped for versioning and codec uniformity.
1335#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1336pub struct Envelope<T> {
1337    pub protocol_version: u16,
1338    pub payload: T,
1339}
1340
1341impl<T> Envelope<T> {
1342    pub fn new(payload: T) -> Self {
1343        Self {
1344            protocol_version: crate::PROTOCOL_VERSION,
1345            payload,
1346        }
1347    }
1348}
1349
1350/// Session handshake after transport connect.
1351#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1352pub struct Hello {
1353    pub client_name: String,
1354    pub protocol_version: u16,
1355    #[serde(default)]
1356    pub auth: AuthCredential,
1357    /// Required for session auth; embedded in `ApiToken` variant otherwise.
1358    #[serde(default)]
1359    pub character_id: Option<Uuid>,
1360}
1361
1362/// How the client authenticates to the game gateway.
1363/// Uses default serde enum encoding (postcard-compatible; not internally tagged).
1364#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1365#[serde(rename_all = "snake_case")]
1366pub enum AuthCredential {
1367    DevLocal,
1368    Session { token: String },
1369    ApiToken { token: String, character_id: Uuid },
1370}
1371
1372impl Default for AuthCredential {
1373    fn default() -> Self {
1374        Self::DevLocal
1375    }
1376}
1377
1378#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1379pub struct Welcome {
1380    pub session_id: SessionId,
1381    pub entity_id: EntityId,
1382    pub snapshot: Snapshot,
1383}
1384
1385#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1386pub enum ServerMessage {
1387    Welcome(Welcome),
1388    /// Static world layers refreshed (blueprints, catalog, map, terrain) — no client restart.
1389    ContentUpdated(Snapshot),
1390    Tick(TickDelta),
1391    IntentAck {
1392        entity_id: EntityId,
1393        seq: Seq,
1394        tick: Tick,
1395    },
1396    Chat(ChatMessage),
1397    HarvestResult(HarvestResult),
1398    UseResult(UseResult),
1399    CraftResult(CraftResult),
1400    Death(DeathNotice),
1401    Interaction(InteractionNotice),
1402    ShopOpened(ShopCatalog),
1403}
1404
1405#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1406pub enum ClientMessage {
1407    Hello(Hello),
1408    Intent(Intent),
1409    Disconnect,
1410}
1411
1412#[cfg(test)]
1413mod tests {
1414    use super::*;
1415
1416    #[test]
1417    fn pristine_vitals_state_yields_full_pools() {
1418        let attrs = PrimaryAttributes::default();
1419        let vitals = StoredVitalsState::default().apply_to(attrs);
1420        assert!(vitals.health > 0.0);
1421        assert_eq!(vitals.health, vitals.health_max);
1422        assert!((vitals.mana_max - 145.0).abs() < 0.01);
1423    }
1424
1425    #[test]
1426    fn saved_vitals_scale_when_pool_max_increases() {
1427        let mut attrs = PrimaryAttributes::default();
1428        attrs.intelligence = 140;
1429        attrs.wisdom = 140;
1430        let saved = StoredVitalsState {
1431            health: 100.0,
1432            mana: 14.0,
1433            stamina: 100.0,
1434            ..StoredVitalsState::default()
1435        };
1436        let vitals = saved.apply_to(attrs);
1437        assert!(vitals.mana_max > 55.0);
1438        assert!(
1439            (vitals.mana - vitals.mana_max).abs() < 0.01,
1440            "full legacy mana bar migrates to full new bar"
1441        );
1442    }
1443
1444    #[test]
1445    fn empty_vitals_state_is_pristine() {
1446        let pristine = StoredVitalsState {
1447            health: 0.0,
1448            mana: 0.0,
1449            stamina: 0.0,
1450            hunger: 0.0,
1451            thirst: 0.0,
1452            coins: 0,
1453            deaths: 0,
1454            life_state: LifeState::Alive,
1455        };
1456        assert!(pristine.is_pristine());
1457        let vitals = pristine.apply_to(PrimaryAttributes::default());
1458        assert!(vitals.health > 0.0);
1459    }
1460
1461    #[test]
1462    fn stored_vitals_roundtrip_preserves_partial_pools() {
1463        let attrs = PrimaryAttributes::default();
1464        let mut live = PlayerVitals::from_attributes(attrs);
1465        live.health = 42.0;
1466        live.hunger = 77.0;
1467        live.deaths = 2;
1468        let stored = StoredVitalsState::from_live(&live);
1469        let restored = stored.apply_to(attrs);
1470        assert!((restored.health - 42.0).abs() < 0.01, "partial HP below old cap stays absolute");
1471        assert_eq!(restored.hunger, 77.0);
1472        assert_eq!(restored.deaths, 2);
1473    }
1474
1475    #[test]
1476    fn skill_tiers_start_at_zero() {
1477        let skill = SkillProgress::default();
1478        assert_eq!(skill.level, 0);
1479        assert_eq!(skill.display_tier(), 0);
1480        let trained = SkillProgress {
1481            level: 250,
1482            last_trained_tick: 1,
1483        };
1484        assert_eq!(trained.display_tier(), 2);
1485    }
1486}