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: 150,
138            dexterity: 150,
139            intelligence: 150,
140            stamina: 150,
141            vitality: 150,
142            wisdom: 150,
143            charisma: 150,
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/// Fractional XP pools — source of truth for progression curve (`plans/27`).
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
208#[serde(default)]
209pub struct ProgressionXp {
210    pub strength: f64,
211    pub dexterity: f64,
212    pub intelligence: f64,
213    pub stamina: f64,
214    pub vitality: f64,
215    pub wisdom: f64,
216    pub charisma: f64,
217    pub logging: f64,
218    pub mining: f64,
219    pub evocation: f64,
220    pub restoration: f64,
221    pub swords: f64,
222    pub archery: f64,
223    pub crafting: f64,
224    pub alchemy: f64,
225    pub cartography: f64,
226}
227
228impl ProgressionXp {
229    /// Bootstrap pools for a new character at display baseline 15.
230    pub fn bootstrap_new(baseline_display: u16, xp_base: f64, xp_growth: f64) -> Self {
231        let bootstrap = |display: f64| {
232            if display <= 1.0 {
233                0.0
234            } else {
235                xp_base * xp_growth.powf(display - 1.0)
236            }
237        };
238        let b = baseline_display as f64;
239        let primary = bootstrap(b);
240        Self {
241            strength: primary,
242            dexterity: primary,
243            intelligence: primary,
244            stamina: primary,
245            vitality: primary,
246            wisdom: primary,
247            charisma: primary,
248            ..Self::default()
249        }
250    }
251
252    pub fn is_empty(&self) -> bool {
253        self.strength == 0.0
254            && self.dexterity == 0.0
255            && self.intelligence == 0.0
256            && self.stamina == 0.0
257            && self.vitality == 0.0
258            && self.wisdom == 0.0
259            && self.charisma == 0.0
260            && self.logging == 0.0
261            && self.mining == 0.0
262            && self.evocation == 0.0
263            && self.restoration == 0.0
264            && self.swords == 0.0
265            && self.archery == 0.0
266            && self.crafting == 0.0
267            && self.alchemy == 0.0
268            && self.cartography == 0.0
269    }
270}
271
272/// Core trained skills shipped at launch (`plans/11` §10).
273#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
274#[serde(default)]
275pub struct PlayerSkills {
276    pub logging: SkillProgress,
277    pub mining: SkillProgress,
278    pub evocation: SkillProgress,
279    #[serde(default)]
280    pub restoration: SkillProgress,
281    pub swords: SkillProgress,
282    #[serde(default)]
283    pub archery: SkillProgress,
284    pub crafting: SkillProgress,
285    #[serde(default)]
286    pub alchemy: SkillProgress,
287    pub cartography: SkillProgress,
288}
289
290impl Default for PlayerSkills {
291    fn default() -> Self {
292        Self {
293            logging: SkillProgress::default(),
294            mining: SkillProgress::default(),
295            evocation: SkillProgress::default(),
296            restoration: SkillProgress::default(),
297            swords: SkillProgress::default(),
298            archery: SkillProgress::default(),
299            crafting: SkillProgress::default(),
300            alchemy: SkillProgress::default(),
301            cartography: SkillProgress::default(),
302        }
303    }
304}
305
306/// Player health / resources (players only; omitted on other entities).
307#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
308pub struct PlayerVitals {
309    pub health: f32,
310    pub health_max: f32,
311    pub mana: f32,
312    pub mana_max: f32,
313    pub stamina: f32,
314    pub stamina_max: f32,
315    #[serde(default = "default_survival_pool_max")]
316    pub hunger: f32,
317    #[serde(default = "default_survival_pool_max")]
318    pub hunger_max: f32,
319    #[serde(default = "default_survival_pool_max")]
320    pub thirst: f32,
321    #[serde(default = "default_survival_pool_max")]
322    pub thirst_max: f32,
323    #[serde(default)]
324    pub coins: u32,
325    #[serde(default)]
326    pub deaths: u32,
327    #[serde(default)]
328    pub life_state: LifeState,
329}
330
331fn default_survival_pool_max() -> f32 {
332    100.0
333}
334
335impl Default for PlayerVitals {
336    fn default() -> Self {
337        Self::from_attributes(PrimaryAttributes::default())
338    }
339}
340
341impl PlayerVitals {
342    /// Build pool maximums from primary attributes (current pools filled to max).
343    ///
344    /// Uses **display** stats (internal ÷ 10) so pools scale intuitively with
345    /// progression, gear, and future enchant modifiers on attributes.
346    ///
347    /// Default attrs (all 150 → display 15): ~80 HP, ~51 stamina, ~61 mana.
348    pub fn from_attributes(attrs: PrimaryAttributes) -> Self {
349        let vit_d = PrimaryAttributes::display(attrs.vitality) as f32;
350        let int_d = PrimaryAttributes::display(attrs.intelligence) as f32;
351        let wis_d = PrimaryAttributes::display(attrs.wisdom) as f32;
352        let sta_d = PrimaryAttributes::display(attrs.stamina) as f32;
353
354        let health_max = 50.0 + vit_d * 2.0;
355        let stamina_max = 30.0 + sta_d * 1.4;
356        let mana_max = 25.0 + int_d * 1.1 + wis_d * 1.3;
357        let hunger_max = 100.0;
358        let thirst_max = 100.0;
359        Self {
360            health: health_max,
361            health_max,
362            mana: mana_max,
363            mana_max,
364            stamina: stamina_max,
365            stamina_max,
366            hunger: hunger_max,
367            hunger_max,
368            thirst: thirst_max,
369            thirst_max,
370            coins: 0,
371            deaths: 0,
372            life_state: LifeState::Alive,
373        }
374    }
375
376    /// Legacy pool maxima (pre-2026-07 pool rebalance) for migration scaling.
377    pub fn legacy_maxima(attrs: PrimaryAttributes) -> (f32, f32, f32) {
378        (
379            attrs.vitality as f32 / 5.0,
380            attrs.stamina as f32 / 5.0,
381            (attrs.intelligence as f32 + attrs.wisdom as f32) / 20.0,
382        )
383    }
384}
385
386/// Durable pool snapshot for Postgres (max values recomputed from attributes on load).
387#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
388#[serde(default)]
389pub struct StoredVitalsState {
390    pub health: f32,
391    pub mana: f32,
392    pub stamina: f32,
393    pub hunger: f32,
394    pub thirst: f32,
395    pub coins: u32,
396    pub deaths: u32,
397    pub life_state: LifeState,
398}
399
400impl StoredVitalsState {
401    pub fn from_live(v: &PlayerVitals) -> Self {
402        Self {
403            health: v.health,
404            mana: v.mana,
405            stamina: v.stamina,
406            hunger: v.hunger,
407            thirst: v.thirst,
408            coins: v.coins,
409            deaths: v.deaths,
410            life_state: v.life_state,
411        }
412    }
413
414    /// Empty `{}` JSON from a new character row — not a real gameplay snapshot.
415    pub fn is_pristine(&self) -> bool {
416        self.health == 0.0
417            && self.mana == 0.0
418            && self.stamina == 0.0
419            && self.hunger == 0.0
420            && self.thirst == 0.0
421            && self.coins == 0
422            && self.deaths == 0
423            && self.life_state == LifeState::Alive
424    }
425
426    pub fn apply_to(self, attrs: PrimaryAttributes) -> PlayerVitals {
427        if self.is_pristine() {
428            return PlayerVitals::from_attributes(attrs);
429        }
430        let fresh = PlayerVitals::from_attributes(attrs);
431        let (legacy_hp, legacy_sta, legacy_mana) = PlayerVitals::legacy_maxima(attrs);
432
433        let scale = |current: f32, legacy_max: f32, new_max: f32| {
434            if legacy_max > 0.0 && new_max > legacy_max * 1.05 && current >= legacy_max * 0.95 {
435                let ratio = (current / legacy_max).clamp(0.0, 1.0);
436                (new_max * ratio).min(new_max)
437            } else {
438                current.min(new_max)
439            }
440        };
441
442        let mut v = fresh;
443        v.health = scale(self.health, legacy_hp, fresh.health_max);
444        v.mana = scale(self.mana, legacy_mana, fresh.mana_max);
445        v.stamina = scale(self.stamina, legacy_sta, fresh.stamina_max);
446        v.hunger = self.hunger.min(v.hunger_max);
447        v.thirst = self.thirst.min(v.thirst_max);
448        v.coins = self.coins;
449        v.deaths = self.deaths;
450        v.life_state = self.life_state;
451        v
452    }
453}
454
455impl Default for StoredVitalsState {
456    fn default() -> Self {
457        Self::from_live(&PlayerVitals::default())
458    }
459}
460
461/// Saved ability rotation preset (`plans/26` §C3).
462#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
463pub struct RotationPreset {
464    pub id: String,
465    pub label: String,
466    #[serde(default)]
467    pub abilities: Vec<String>,
468}
469
470impl RotationPreset {
471    pub fn melee_default(ability_id: impl Into<String>) -> Self {
472        let id = ability_id.into();
473        Self {
474            id: "melee".into(),
475            label: "Melee".into(),
476            abilities: vec![id],
477        }
478    }
479}
480
481/// Durable combat state (`plans/12` §4.1) — target by wildlife instance id, not entity id.
482#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
483pub struct StoredTargetSlot {
484    pub instance_id: Option<String>,
485    #[serde(default)]
486    pub preset_id: Option<String>,
487    #[serde(default)]
488    pub rotation_index: u32,
489    #[serde(default)]
490    pub auto_enabled: bool,
491}
492
493#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
494#[serde(default)]
495pub struct StoredCombatProfile {
496    /// Wildlife `instance_id` (e.g. `meadow-rabbits-0`), stable across worker restarts.
497    pub combat_target_instance_id: Option<String>,
498    pub in_combat: bool,
499    pub last_combat_tick: u64,
500    pub last_attack_tick: u64,
501    pub cooldowns_until_tick: BTreeMap<String, u64>,
502    #[serde(default = "default_auto_attack")]
503    pub auto_attack_enabled: bool,
504    /// Equipped mainhand weapon template id (`plans/26` §C2b).
505    #[serde(default)]
506    pub mainhand_template_id: Option<String>,
507    /// Equipped body-slot items (backpack, belt, future armor), nested contents included
508    /// (e.g. pouches clipped onto a worn belt). At most one entry per `BodySlot`.
509    #[serde(default)]
510    pub worn: Vec<(BodySlot, ItemStack)>,
511    /// Player-authored rotation library (`plans/26` §C3).
512    #[serde(default)]
513    pub rotation_presets: Vec<RotationPreset>,
514    /// Per-target slot state (T1/T2).
515    #[serde(default)]
516    pub target_slots: Vec<StoredTargetSlot>,
517    /// Discovered craft recipe ids (`plans/09` §C2 hybrid discovery).
518    #[serde(default)]
519    pub known_blueprint_ids: Vec<String>,
520    /// Keys stowed on the keychain (not counted toward carry mass).
521    #[serde(default)]
522    pub keychain: Vec<ItemStack>,
523}
524
525fn default_auto_attack() -> bool {
526    true
527}
528
529impl Default for StoredCombatProfile {
530    fn default() -> Self {
531        Self {
532            combat_target_instance_id: None,
533            in_combat: false,
534            last_combat_tick: 0,
535            last_attack_tick: 0,
536            cooldowns_until_tick: BTreeMap::new(),
537            auto_attack_enabled: true,
538            mainhand_template_id: None,
539            worn: Vec::new(),
540            rotation_presets: Vec::new(),
541            target_slots: Vec::new(),
542            known_blueprint_ids: Vec::new(),
543            keychain: Vec::new(),
544        }
545    }
546}
547
548#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
549pub struct EntityState {
550    pub id: EntityId,
551    pub transform: Transform,
552    /// Character / display name (first letter used as map glyph for other players).
553    #[serde(default)]
554    pub label: String,
555    #[serde(default)]
556    pub vitals: Option<PlayerVitals>,
557    /// Primary attributes (local player / inspect).
558    #[serde(default)]
559    pub attributes: Option<PrimaryAttributes>,
560    #[serde(default)]
561    pub skills: Option<PlayerSkills>,
562    /// When inside a building interior (players only).
563    #[serde(default)]
564    pub inside_building: Option<String>,
565    /// Gfx sprite sheet id for player avatars (`player.hero`, …).
566    #[serde(default)]
567    pub tile_id: Option<String>,
568    /// Canonical gfx presentation key (`walking`, `combat`, `harvesting`, …).
569    #[serde(default)]
570    pub presentation_state: Option<String>,
571    /// Resolved gfx sprite mode for `tile_id` (server-computed).
572    #[serde(default)]
573    pub sprite_mode: Option<String>,
574    /// XP pools for local player progression display (omitted on NPCs / other players).
575    #[serde(default)]
576    pub progression_xp: Option<ProgressionXp>,
577}
578
579/// Nearby voice chat vs magical long-range (requires whisper-stone item).
580#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
581#[serde(rename_all = "snake_case")]
582pub enum ChatChannel {
583    Nearby,
584    WhisperStone,
585}
586
587#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
588pub struct ChatMessage {
589    pub channel: ChatChannel,
590    pub from_entity: EntityId,
591    pub from_name: String,
592    pub text: String,
593    pub tick: Tick,
594}
595
596/// Client → server gameplay input (reliable, sequenced per entity).
597#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
598pub enum Intent {
599    Move {
600        entity_id: EntityId,
601        forward: f32,
602        strafe: f32,
603        /// Climb (+) or descend (−) on z axis (m/s intent).
604        #[serde(default)]
605        vertical: f32,
606        /// Sprint multiplier when stamina allows.
607        #[serde(default)]
608        sprint: bool,
609        seq: Seq,
610    },
611    Stop {
612        entity_id: EntityId,
613        seq: Seq,
614    },
615    Harvest {
616        entity_id: EntityId,
617        node_id: String,
618        seq: Seq,
619    },
620    Use {
621        entity_id: EntityId,
622        template_id: String,
623        seq: Seq,
624    },
625    Say {
626        entity_id: EntityId,
627        channel: ChatChannel,
628        text: String,
629        seq: Seq,
630    },
631    /// Start a blueprint craft (timed, like harvest).
632    Craft {
633        entity_id: EntityId,
634        blueprint_id: String,
635        /// Batches to run back-to-back; `None` crafts as many as materials/stamina allow.
636        #[serde(default)]
637        count: Option<u32>,
638        seq: Seq,
639    },
640    /// Door, NPC, enter/exit building.
641    Interact {
642        entity_id: EntityId,
643        target_id: String,
644        seq: Seq,
645    },
646    /// Buy from an NPC shop offer (`ShopOpened` catalog).
647    ShopBuy {
648        entity_id: EntityId,
649        npc_id: String,
650        offer_id: String,
651        #[serde(default = "default_one")]
652        quantity: u32,
653        seq: Seq,
654    },
655    /// Sell inventory to an NPC (`ShopOpened` buy list).
656    ShopSell {
657        entity_id: EntityId,
658        npc_id: String,
659        template_id: String,
660        #[serde(default = "default_one")]
661        quantity: u32,
662        seq: Seq,
663    },
664    /// Dev / test: apply damage to self (co-op testing).
665    TestDamage {
666        entity_id: EntityId,
667        amount: f32,
668        seq: Seq,
669    },
670    /// Slot-1 combat target (`plans/12` alias).
671    SetTarget {
672        entity_id: EntityId,
673        target_id: EntityId,
674        seq: Seq,
675    },
676    /// Target slot assignment (`plans/12` §5.3). Slot 1 aliases `SetTarget`.
677    SetTargetSlot {
678        entity_id: EntityId,
679        slot_index: u8,
680        target_id: EntityId,
681        seq: Seq,
682    },
683    ClearTarget {
684        entity_id: EntityId,
685        seq: Seq,
686    },
687    ClearTargetSlot {
688        entity_id: EntityId,
689        slot_index: u8,
690        seq: Seq,
691    },
692    /// Toggle per-slot auto-attack (`plans/12` §4.2).
693    SetAutoAttack {
694        entity_id: EntityId,
695        slot_index: u8,
696        enabled: bool,
697        seq: Seq,
698    },
699    /// Melee attack — uses `target_id` or the player's current target.
700    Attack {
701        entity_id: EntityId,
702        #[serde(default)]
703        target_id: Option<EntityId>,
704        #[serde(default)]
705        weapon_slot: Option<u32>,
706        seq: Seq,
707    },
708    /// Pick up a ground loot pile (nearest in range when `drop_id` omitted).
709    Pickup {
710        entity_id: EntityId,
711        #[serde(default)]
712        drop_id: Option<String>,
713        seq: Seq,
714    },
715    /// Cast a spell or use a non-weapon ability template (`plans/24` §4.3b).
716    Cast {
717        entity_id: EntityId,
718        ability_id: String,
719        target_id: EntityId,
720        seq: Seq,
721    },
722    /// Bind an ability to a target slot action bar (`plans/12` §4.2).
723    BindActionSlot {
724        entity_id: EntityId,
725        slot_index: u8,
726        ability_id: String,
727        #[serde(default = "default_auto_attack")]
728        auto_enabled: bool,
729        seq: Seq,
730    },
731    /// Fire a bound consumable or ability from a slot (`plans/24` §4.3c).
732    UseActionSlot {
733        entity_id: EntityId,
734        slot_index: u8,
735        seq: Seq,
736    },
737    /// Dodge — brief i-frames, stamina cost (`plans/24` §4.3c).
738    Dodge {
739        entity_id: EntityId,
740        seq: Seq,
741    },
742    /// Lunge — burst forward in movement/facing direction, higher stamina cost.
743    Lunge {
744        entity_id: EntityId,
745        /// Last movement forward axis when idle (−1..1).
746        #[serde(default)]
747        forward: f32,
748        /// Last movement strafe axis when idle (−1..1).
749        #[serde(default)]
750        strafe: f32,
751        seq: Seq,
752    },
753    /// Directional jump — leap one cell uphill over a medium cliff (`plans/04` §4.5b).
754    DirectionalJump {
755        entity_id: EntityId,
756        /// Jump direction forward axis (−1..1).
757        #[serde(default)]
758        forward: f32,
759        /// Jump direction strafe axis (−1..1).
760        #[serde(default)]
761        strafe: f32,
762        seq: Seq,
763    },
764    /// Block — frontal mitigation while held (`plans/24` §4.3c).
765    Block {
766        entity_id: EntityId,
767        #[serde(default = "default_block_enabled")]
768        enabled: bool,
769        seq: Seq,
770    },
771    /// Equip or clear mainhand weapon (`plans/26` §C2b). `None` unequips.
772    EquipMainhand {
773        entity_id: EntityId,
774        #[serde(default)]
775        template_id: Option<String>,
776        seq: Seq,
777    },
778    /// Equip a wearable (container, belt, or armor piece) to a body slot, or clear the
779    /// slot when `instance_id` is None.
780    EquipWorn {
781        entity_id: EntityId,
782        slot: BodySlot,
783        #[serde(default)]
784        instance_id: Option<Uuid>,
785        seq: Seq,
786    },
787    /// Move an item instance between root / worn / placed container inventories.
788    MoveItem {
789        entity_id: EntityId,
790        item_instance_id: Uuid,
791        from: InventoryLocation,
792        to: InventoryLocation,
793        /// When moving into a container location, nest under this parent instance (None = container root).
794        #[serde(default)]
795        to_parent_instance_id: Option<Uuid>,
796        /// Units to move; `None` moves the whole stack. Server clamps to volume/carry limits.
797        #[serde(default)]
798        quantity: Option<u32>,
799        seq: Seq,
800    },
801    /// Place a placeable container from inventory onto the ground at the player's feet.
802    PlaceContainer {
803        entity_id: EntityId,
804        item_instance_id: Uuid,
805        seq: Seq,
806    },
807    /// Pick up a placed container (with contents) into inventory.
808    PickupContainer {
809        entity_id: EntityId,
810        container_id: String,
811        seq: Seq,
812    },
813    /// Lock or unlock a worn or placed container (requires matching key when locking/unlocking).
814    SetContainerLocked {
815        entity_id: EntityId,
816        /// Worn slot or placed container id encoded as location.
817        location: InventoryLocation,
818        locked: bool,
819        seq: Seq,
820    },
821    /// Drop a non-container item on the ground at the player's feet.
822    DropItem {
823        entity_id: EntityId,
824        item_instance_id: Uuid,
825        from: InventoryLocation,
826        seq: Seq,
827    },
828    /// Permanently destroy an item stack (not recoverable; no ground drop).
829    DestroyItem {
830        entity_id: EntityId,
831        item_instance_id: Uuid,
832        from: InventoryLocation,
833        /// Units to destroy; `None` destroys the whole stack.
834        #[serde(default)]
835        quantity: Option<u32>,
836        seq: Seq,
837    },
838    /// Set a custom display name on a container instance (chest, pouch, backpack).
839    RenameContainer {
840        entity_id: EntityId,
841        item_instance_id: Uuid,
842        location: InventoryLocation,
843        name: String,
844        seq: Seq,
845    },
846    /// Create or update a rotation preset in the player's library.
847    UpsertRotationPreset {
848        entity_id: EntityId,
849        preset: RotationPreset,
850        seq: Seq,
851    },
852    /// Remove a rotation preset from the library.
853    DeleteRotationPreset {
854        entity_id: EntityId,
855        preset_id: String,
856        seq: Seq,
857    },
858    /// Assign a library preset to target slot T1/T2.
859    AssignSlotPreset {
860        entity_id: EntityId,
861        slot_index: u8,
862        preset_id: String,
863        seq: Seq,
864    },
865    /// Fire the next ready ability in a slot's rotation (manual step).
866    AdvanceRotation {
867        entity_id: EntityId,
868        slot_index: u8,
869        seq: Seq,
870    },
871    /// Open a turn-based conversation with an NPC.
872    NpcTalkOpen {
873        entity_id: EntityId,
874        npc_id: String,
875        seq: Seq,
876    },
877    /// Send a player message in an open NPC conversation.
878    NpcTalkSay {
879        entity_id: EntityId,
880        npc_id: String,
881        message: String,
882        seq: Seq,
883    },
884    /// Close an NPC conversation.
885    NpcTalkClose {
886        entity_id: EntityId,
887        npc_id: String,
888        seq: Seq,
889    },
890    /// Accept a discovered quest (adds to active list).
891    AcceptQuest {
892        entity_id: EntityId,
893        quest_id: String,
894        seq: Seq,
895    },
896    /// Withdraw from an active quest (resets progress; can re-accept).
897    WithdrawQuest {
898        entity_id: EntityId,
899        quest_id: String,
900        seq: Seq,
901    },
902    /// Highlight an active quest in the HUD.
903    TrackQuest {
904        entity_id: EntityId,
905        quest_id: String,
906        seq: Seq,
907    },
908    /// Turn in items for an active give_item objective while near an NPC.
909    QuestGiveItem {
910        entity_id: EntityId,
911        npc_id: String,
912        template_id: String,
913        #[serde(default = "default_one")]
914        quantity: u32,
915        seq: Seq,
916    },
917    /// Hire an NPC worker (fee + recurring wage).
918    HireWorker {
919        entity_id: EntityId,
920        def_id: String,
921        wage_copper_per_interval: u32,
922        #[serde(default)]
923        lodging_container_id: Option<String>,
924        #[serde(default)]
925        job_yaml: Option<String>,
926        seq: Seq,
927    },
928    /// Release a hired worker instance.
929    DismissWorker {
930        entity_id: EntityId,
931        worker_instance_id: String,
932        seq: Seq,
933    },
934    /// Replace or assign a worker job YAML loop.
935    SetWorkerJob {
936        entity_id: EntityId,
937        worker_instance_id: String,
938        job_yaml: String,
939        seq: Seq,
940    },
941    /// Point a worker at a camp bed / lodging container.
942    AssignWorkerLodging {
943        entity_id: EntityId,
944        worker_instance_id: String,
945        lodging_container_id: String,
946        seq: Seq,
947    },
948    /// Switch companion vs job-loop automation mode.
949    SetWorkerMode {
950        entity_id: EntityId,
951        worker_instance_id: String,
952        mode: String,
953        seq: Seq,
954    },
955    /// Hand an item from the player's inventory to a hired worker (e.g. a tool the
956    /// worker must carry but not consume, like a handsaw for `oak_to_lumber`).
957    GiveWorkerItem {
958        entity_id: EntityId,
959        worker_instance_id: String,
960        item_instance_id: uuid::Uuid,
961        #[serde(default)]
962        quantity: Option<u32>,
963        seq: Seq,
964    },
965    /// Set a custom display name for a hired worker (shown in menus / route editor).
966    RenameHiredWorker {
967        entity_id: EntityId,
968        worker_instance_id: String,
969        name: String,
970        seq: Seq,
971    },
972    /// Teach a known blueprint to a hired worker (costs `worker_train_copper`).
973    TeachWorkerBlueprint {
974        entity_id: EntityId,
975        worker_instance_id: String,
976        blueprint_id: String,
977        seq: Seq,
978    },
979}
980
981fn default_block_enabled() -> bool {
982    true
983}
984
985/// Observer combat HUD (local player only).
986#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
987pub struct CombatTargetHud {
988    pub entity_id: EntityId,
989    #[serde(default)]
990    pub label: String,
991    #[serde(default)]
992    pub level: u32,
993    pub health: f32,
994    pub health_max: f32,
995    #[serde(default)]
996    pub life_state: LifeState,
997    #[serde(default)]
998    pub distance_m: f32,
999}
1000
1001/// Active spell cast channel progress.
1002#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1003pub struct CastProgressHud {
1004    #[serde(default)]
1005    pub ability_id: String,
1006    #[serde(default)]
1007    pub ability_label: String,
1008    #[serde(default)]
1009    pub ticks_remaining: u64,
1010    #[serde(default)]
1011    pub ticks_total: u64,
1012}
1013
1014/// Cooldown state for a combat ability shown on the action bar.
1015#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1016pub struct AbilityCooldownHud {
1017    #[serde(default)]
1018    pub ability_id: String,
1019    #[serde(default)]
1020    pub label: String,
1021    #[serde(default)]
1022    pub cd_ticks: u64,
1023    #[serde(default)]
1024    pub cd_total_ticks: u64,
1025}
1026
1027/// One combat target slot in the observer HUD.
1028#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1029pub struct CombatSlotHud {
1030    pub slot_index: u8,
1031    #[serde(default)]
1032    pub target_entity_id: Option<EntityId>,
1033    #[serde(default)]
1034    pub target_label: Option<String>,
1035    #[serde(default)]
1036    pub target: Option<CombatTargetHud>,
1037    #[serde(default)]
1038    pub preset_id: Option<String>,
1039    #[serde(default)]
1040    pub preset_label: Option<String>,
1041    #[serde(default)]
1042    pub rotation: Vec<String>,
1043    #[serde(default)]
1044    pub rotation_index: u32,
1045    #[serde(default)]
1046    pub next_ability_id: Option<String>,
1047    #[serde(default)]
1048    pub auto_enabled: bool,
1049}
1050
1051/// Observer combat HUD (local player only).
1052#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1053pub struct CombatHud {
1054    pub in_combat: bool,
1055    /// T1 auto rotation (legacy field mirrors slot 1).
1056    pub auto_attack: bool,
1057    pub has_los: bool,
1058    pub attack_cd_ticks: u64,
1059    #[serde(default)]
1060    pub ability_id: String,
1061    #[serde(default)]
1062    pub target_entity_id: Option<EntityId>,
1063    #[serde(default)]
1064    pub target_label: Option<String>,
1065    #[serde(default)]
1066    pub max_target_slots: u8,
1067    #[serde(default)]
1068    pub slots: Vec<CombatSlotHud>,
1069    #[serde(default)]
1070    pub rotation_presets: Vec<RotationPreset>,
1071    #[serde(default)]
1072    pub gcd_ticks: u64,
1073    #[serde(default)]
1074    pub mainhand_template_id: Option<String>,
1075    #[serde(default)]
1076    pub mainhand_label: Option<String>,
1077    /// Equipped body-slot items (backpack, belt w/ clipped pouches, future armor).
1078    #[serde(default)]
1079    pub worn: Vec<(BodySlot, ItemStack)>,
1080    #[serde(default)]
1081    pub carry_mass: f32,
1082    #[serde(default)]
1083    pub carry_mass_max: f32,
1084    #[serde(default)]
1085    pub encumbrance: EncumbranceState,
1086    /// Keys on the virtual keychain (stowed, zero carry mass).
1087    #[serde(default)]
1088    pub keychain: Vec<ItemStack>,
1089    #[serde(default)]
1090    pub target: Option<CombatTargetHud>,
1091    #[serde(default)]
1092    pub cast: Option<CastProgressHud>,
1093    #[serde(default)]
1094    pub ability_cooldowns: Vec<AbilityCooldownHud>,
1095    #[serde(default)]
1096    pub blocking_active: bool,
1097    /// Live XP pools for the local player (updated every tick with combat HUD).
1098    #[serde(default)]
1099    pub progression_xp: Option<ProgressionXp>,
1100    #[serde(default)]
1101    pub progression_baseline: u16,
1102    #[serde(default)]
1103    pub progression_xp_base: f64,
1104    #[serde(default)]
1105    pub progression_xp_growth: f64,
1106    #[serde(default)]
1107    pub attributes: Option<PrimaryAttributes>,
1108    #[serde(default)]
1109    pub skills: Option<PlayerSkills>,
1110}
1111
1112/// Hired worker automation mode on the wire.
1113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1114#[serde(rename_all = "snake_case")]
1115pub enum WorkerModeView {
1116    Companion,
1117    JobLoop,
1118    /// Deliberately parked — no route, no follow. The worker stands down
1119    /// (still draws wages) until the employer assigns a mode/route again.
1120    Idle,
1121}
1122
1123/// Hired worker FSM state on the wire.
1124#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1125#[serde(rename_all = "snake_case")]
1126pub enum WorkerStateView {
1127    Idle,
1128    Traveling,
1129    Working,
1130    Resting,
1131    Waiting,
1132    Strike,
1133    Dismissed,
1134}
1135
1136/// Compact vitals for hired worker HUD rows.
1137#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
1138pub struct WorkerVitalsSummary {
1139    pub health_pct: f32,
1140    pub stamina_pct: f32,
1141}
1142
1143/// High-level route shape — `harvest_loop` (legacy flat lists) or `ordered` (typed stop list).
1144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1145#[serde(rename_all = "snake_case")]
1146pub enum WorkerRouteKindView {
1147    #[default]
1148    HarvestLoop,
1149    Ordered,
1150}
1151
1152/// Saved worker route for UI / route editor reload.
1153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1154pub struct WorkerRouteView {
1155    #[serde(default)]
1156    pub kind: WorkerRouteKindView,
1157    #[serde(default)]
1158    pub lodging_container_id: Option<String>,
1159    /// Legacy `harvest_loop` waypoint list.
1160    #[serde(default)]
1161    pub outbound_waypoints: Vec<WorkerRouteWaypointView>,
1162    /// Legacy `harvest_loop` node id list.
1163    #[serde(default)]
1164    pub harvest_nodes: Vec<String>,
1165    #[serde(default = "default_route_carry_ratio")]
1166    pub carry_return_ratio: f32,
1167    /// Ordered-route typed stops (`kind: ordered`).
1168    #[serde(default)]
1169    pub stops: Vec<WorkerRouteStopView>,
1170}
1171
1172fn default_route_carry_ratio() -> f32 {
1173    0.90
1174}
1175
1176fn default_true_view() -> bool {
1177    true
1178}
1179
1180/// One withdraw line for a `WithdrawFrom` route stop view.
1181#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1182pub struct WorkerWithdrawItemView {
1183    pub template: String,
1184    #[serde(default)]
1185    pub qty: u32,
1186    /// Take every stack of this template (carry-capped). Wins over `qty`.
1187    #[serde(default)]
1188    pub all: bool,
1189}
1190
1191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1192pub struct WorkerRouteWaypointView {
1193    pub x: f32,
1194    pub y: f32,
1195    pub z: f32,
1196}
1197
1198/// One typed stop in an ordered worker route.
1199///
1200/// NOTE: this is the wire (postcard) view. Postcard does **not** support
1201/// internally-tagged enums (`#[serde(tag = ...)]` — it returns `WontImplement`
1202/// from `deserialize_any`), so this enum uses serde's default **external tagging**.
1203/// The sim-side `WorkerRouteStop` (`crates/sim/src/worker_job.rs`) is the YAML-facing
1204/// twin and keeps its `tag = "stop"` for human-authored job YAML; the two never share
1205/// a wire format.
1206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1207#[serde(rename_all = "snake_case")]
1208pub enum WorkerRouteStopView {
1209    Waypoint {
1210        x: f32,
1211        y: f32,
1212        #[serde(default)]
1213        z: f32,
1214    },
1215    HarvestNode {
1216        node_id: String,
1217    },
1218    DepositAt {
1219        container_id: String,
1220        #[serde(default)]
1221        filter: Option<Vec<String>>,
1222    },
1223    TradeWith {
1224        #[serde(default)]
1225        npc_id: Option<String>,
1226        template: String,
1227        #[serde(default = "default_true_view")]
1228        sell_all: bool,
1229    },
1230    WithdrawFrom {
1231        container_id: String,
1232        items: Vec<WorkerWithdrawItemView>,
1233    },
1234    CraftAt {
1235        device: String,
1236        blueprint: String,
1237        #[serde(default)]
1238        qty: Option<u32>,
1239    },
1240    RestIfNeeded,
1241    Wait {
1242        #[serde(default)]
1243        wait_ticks: u64,
1244    },
1245}
1246
1247/// One player-hired worker visible in snapshot / tick deltas.
1248#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1249pub struct HiredWorkerView {
1250    pub instance_id: String,
1251    pub entity_id: EntityId,
1252    pub def_id: String,
1253    /// Display label (custom name when set, otherwise the NPC def label).
1254    pub label: String,
1255    pub x: f32,
1256    pub y: f32,
1257    pub z: f32,
1258    pub mode: WorkerModeView,
1259    pub state: WorkerStateView,
1260    #[serde(default)]
1261    pub step_label: String,
1262    pub vitals: WorkerVitalsSummary,
1263    #[serde(default)]
1264    pub carry_pct: f32,
1265    #[serde(default)]
1266    pub last_error: Option<String>,
1267    pub wage_copper_per_interval: u32,
1268    /// Placed camp bed / lodging container this worker uses for deposit and rest.
1269    #[serde(default)]
1270    pub lodging_container_id: Option<String>,
1271    /// High-level harvest route (when job_loop route is configured).
1272    #[serde(default)]
1273    pub route: Option<WorkerRouteView>,
1274    /// Recipes this worker already knows (from hire `teaches` + employer teach).
1275    #[serde(default)]
1276    pub known_blueprint_ids: Vec<String>,
1277    /// Overall worker level (1+).
1278    #[serde(default = "default_worker_view_level")]
1279    pub level: u32,
1280    /// Cumulative worker XP.
1281    #[serde(default)]
1282    pub worker_xp: f64,
1283}
1284
1285fn default_worker_view_level() -> u32 {
1286    1
1287}
1288
1289/// Server → client AOI-filtered entity updates for one sim tick.
1290#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1291pub struct TickDelta {
1292    pub tick: Tick,
1293    pub entities: Vec<EntityState>,
1294    #[serde(default)]
1295    pub resource_nodes: Vec<ResourceNodeView>,
1296    #[serde(default)]
1297    pub buildings: Vec<BuildingView>,
1298    #[serde(default)]
1299    pub doors: Vec<DoorView>,
1300    #[serde(default)]
1301    pub npcs: Vec<NpcView>,
1302    /// Observer inventory stacks (template → qty).
1303    #[serde(default)]
1304    pub inventory: Vec<ItemStack>,
1305    #[serde(default)]
1306    pub blueprints: Vec<BlueprintView>,
1307    #[serde(default)]
1308    pub world_clock: WorldClock,
1309    #[serde(default)]
1310    pub ground_drops: Vec<GroundDropView>,
1311    #[serde(default)]
1312    pub placed_containers: Vec<PlacedContainerView>,
1313    #[serde(default)]
1314    pub combat: Option<CombatHud>,
1315    #[serde(default)]
1316    pub interior_map: Option<InteriorMapView>,
1317    #[serde(default)]
1318    pub quest_log: Vec<QuestLogEntry>,
1319    #[serde(default)]
1320    pub hired_workers: Vec<HiredWorkerView>,
1321    #[serde(default)]
1322    pub interactables: Vec<InteractableView>,
1323}
1324#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1325pub struct GroundDropView {
1326    pub id: String,
1327    pub template_id: String,
1328    pub quantity: u32,
1329    pub x: f32,
1330    pub y: f32,
1331    pub z: f32,
1332    /// Optional gfx tile id from the item template.
1333    #[serde(default)]
1334    pub tile_id: Option<String>,
1335}
1336
1337/// Full state on region enter or reconnect.
1338#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1339pub struct Snapshot {
1340    pub tick: Tick,
1341    pub chunk_rev: u64,
1342    /// Bumped when blueprints, catalog, segment, or settings reload.
1343    #[serde(default)]
1344    pub content_rev: u64,
1345    /// Stable publish revision from `assets/.content-publish.json` (for client asset sync).
1346    #[serde(default)]
1347    pub publish_rev: u64,
1348    pub entities: Vec<EntityState>,
1349    #[serde(default)]
1350    pub resource_nodes: Vec<ResourceNodeView>,
1351    /// Segment play area width in meters (for HUD).
1352    #[serde(default)]
1353    pub world_width_m: f32,
1354    #[serde(default)]
1355    pub world_height_m: f32,
1356    #[serde(default)]
1357    pub buildings: Vec<BuildingView>,
1358    #[serde(default)]
1359    pub doors: Vec<DoorView>,
1360    #[serde(default)]
1361    pub npcs: Vec<NpcView>,
1362    #[serde(default)]
1363    pub inventory: Vec<ItemStack>,
1364    #[serde(default)]
1365    pub blueprints: Vec<BlueprintView>,
1366    #[serde(default)]
1367    pub world_clock: WorldClock,
1368    #[serde(default)]
1369    pub terrain_zones: Vec<TerrainZoneView>,
1370    #[serde(default)]
1371    pub z_platforms: Vec<ZPlatformView>,
1372    #[serde(default)]
1373    pub z_transitions: Vec<ZTransitionView>,
1374    #[serde(default)]
1375    pub ground_drops: Vec<GroundDropView>,
1376    #[serde(default)]
1377    pub placed_containers: Vec<PlacedContainerView>,
1378    #[serde(default)]
1379    pub combat: Option<CombatHud>,
1380    #[serde(default)]
1381    pub interior_map: Option<InteriorMapView>,
1382    #[serde(default)]
1383    pub quest_log: Vec<QuestLogEntry>,
1384    #[serde(default)]
1385    pub hired_workers: Vec<HiredWorkerView>,
1386    #[serde(default)]
1387    pub interactables: Vec<InteractableView>,
1388}
1389
1390/// Harvestable node visible to clients.
1391#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1392pub struct ResourceNodeView {
1393    pub id: String,
1394    pub label: String,
1395    pub x: f32,
1396    pub y: f32,
1397    pub z: f32,
1398    pub item_template: String,
1399    #[serde(default = "default_node_state")]
1400    pub state: ResourceNodeState,
1401    /// When true, players cannot walk through this node while available/harvesting.
1402    #[serde(default = "default_blocking_view")]
1403    pub blocking: bool,
1404    /// Collision radius for pathfinding (meters).
1405    #[serde(default = "default_blocking_radius_view")]
1406    pub blocking_radius_m: f32,
1407    /// Optional gfx tile id (`resource.oak_log`, …).
1408    #[serde(default)]
1409    pub tile_id: Option<String>,
1410    /// Resolved gfx sprite mode for `tile_id` (server-computed).
1411    #[serde(default)]
1412    pub sprite_mode: Option<String>,
1413    /// Canonical presentation key (`available`, `harvesting`, `depleted`).
1414    #[serde(default)]
1415    pub presentation_state: Option<String>,
1416}
1417
1418fn default_blocking_radius_view() -> f32 {
1419    0.8
1420}
1421
1422fn default_blocking_view() -> bool {
1423    true
1424}
1425
1426#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1427#[serde(rename_all = "snake_case")]
1428pub enum ResourceNodeState {
1429    Available,
1430    Harvesting,
1431    Cooldown,
1432}
1433
1434fn default_node_state() -> ResourceNodeState {
1435    ResourceNodeState::Available
1436}
1437
1438#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1439pub struct ItemStack {
1440    pub template_id: String,
1441    pub quantity: u32,
1442    /// Stable instance id — preserved across checkpoint/sync when set.
1443    #[serde(default)]
1444    pub item_instance_id: Option<Uuid>,
1445    /// Per-instance metadata (stat rolls, soul-bind, lock ids, etc.).
1446    #[serde(default)]
1447    pub props: BTreeMap<String, String>,
1448    /// Nested contents when this stack is a container instance.
1449    #[serde(default)]
1450    pub contents: Vec<ItemStack>,
1451    /// From item catalog when sent on wire (display only).
1452    #[serde(default)]
1453    pub display_name: Option<String>,
1454    /// From item catalog when sent on wire (`weapon`, `consumable`, …).
1455    #[serde(default)]
1456    pub category: Option<String>,
1457    /// Per-unit mass in kg from catalog (display / move UX).
1458    #[serde(default)]
1459    pub base_mass: Option<f32>,
1460    /// Per-unit volume from catalog (display / move UX).
1461    #[serde(default)]
1462    pub base_volume: Option<f32>,
1463    /// Container capacity when this stack is a container template.
1464    #[serde(default)]
1465    pub capacity_volume: Option<f32>,
1466    /// Whether the template stacks in inventory (from catalog).
1467    #[serde(default)]
1468    pub stackable: Option<bool>,
1469    /// Can be placed on the ground from inventory (from catalog).
1470    #[serde(default)]
1471    pub world_placeable: Option<bool>,
1472    /// Hired-worker lodging capacity when this template is placed (from catalog).
1473    #[serde(default)]
1474    pub worker_lodging_capacity: Option<u32>,
1475}
1476
1477impl ItemStack {
1478    pub fn simple(template_id: impl Into<String>, quantity: u32) -> Self {
1479        Self {
1480            template_id: template_id.into(),
1481            quantity,
1482            ..Default::default()
1483        }
1484    }
1485}
1486
1487impl Default for ItemStack {
1488    fn default() -> Self {
1489        Self {
1490            template_id: String::new(),
1491            quantity: 0,
1492            item_instance_id: None,
1493            props: BTreeMap::new(),
1494            contents: Vec::new(),
1495            display_name: None,
1496            category: None,
1497            base_mass: None,
1498            base_volume: None,
1499            capacity_volume: None,
1500            stackable: None,
1501            world_placeable: None,
1502            worker_lodging_capacity: None,
1503        }
1504    }
1505}
1506
1507/// Carry encumbrance band (`plans/08` §3.2).
1508#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1509#[serde(rename_all = "snake_case")]
1510pub enum EncumbranceState {
1511    #[default]
1512    Light,
1513    Heavy,
1514    Over,
1515}
1516
1517/// Body region a wearable item occupies — at most one item equipped per slot.
1518/// `Head`/`Body`/`Arms`/`Legs`/`Feet` are reserved for future armor (`plans/11`); only
1519/// `Back` (backpack) and `Waist` (belt) have equippable items today (`plans/08` §4.1).
1520#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)]
1521#[serde(rename_all = "snake_case")]
1522pub enum BodySlot {
1523    Head,
1524    Body,
1525    Arms,
1526    Legs,
1527    Feet,
1528    Back,
1529    Waist,
1530}
1531
1532/// Where an item lives for `MoveItem` / open-container UX.
1533#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1534#[serde(rename_all = "snake_case")]
1535pub enum InventoryLocation {
1536    /// Loose on-person inventory (not inside a worn/placed container).
1537    Root,
1538    /// Inside a worn item (backpack contents, or a pouch clipped onto a worn belt).
1539    Worn { slot: BodySlot },
1540    /// Inside a world-placed container.
1541    Placed { container_id: String },
1542    /// Virtual key ring — only `container_key` items; zero carry mass; persists with combat profile.
1543    Keychain,
1544}
1545
1546/// AOI view of a placeable chest on the map.
1547#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1548pub struct PlacedContainerView {
1549    pub id: String,
1550    pub template_id: String,
1551    pub display_name: String,
1552    pub x: f32,
1553    pub y: f32,
1554    pub z: f32,
1555    pub locked: bool,
1556    /// Observer can open (unlocked, or holds matching key).
1557    #[serde(default)]
1558    pub accessible: bool,
1559    #[serde(default)]
1560    pub owner_character_id: Option<Uuid>,
1561    /// Nested contents when `accessible` (empty when locked without key).
1562    #[serde(default)]
1563    pub contents: Vec<ItemStack>,
1564    /// Lock id for matching `container_key` props (`opens_lock_id`).
1565    #[serde(default)]
1566    pub lock_id: Option<String>,
1567    /// Internal storage capacity (liters) from item catalog.
1568    #[serde(default)]
1569    pub capacity_volume: Option<f32>,
1570    /// Container instance id (for MoveItem parent targeting).
1571    #[serde(default)]
1572    pub item_instance_id: Option<Uuid>,
1573    /// Optional gfx tile id from the item template.
1574    #[serde(default)]
1575    pub tile_id: Option<String>,
1576    /// Hired-worker slots when this is placed lodging (`category: lodging`).
1577    #[serde(default)]
1578    pub worker_lodging_capacity: Option<u32>,
1579}
1580
1581#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1582pub struct BlueprintIngredientView {
1583    pub template_id: String,
1584    pub quantity: u32,
1585    /// Always serialize — `true` is not bool::default() so postcard keeps it; explicit for clarity.
1586    pub consumed: bool,
1587}
1588
1589#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1590pub struct ToolRequirementView {
1591    pub item: String,
1592    /// Always serialize — postcard omits `false` by default, which breaks roundtrip without explicit value.
1593    pub consumed: bool,
1594}
1595
1596#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1597pub struct SkillRequirementView {
1598    pub skill: String,
1599    pub level: u32,
1600}
1601
1602#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1603pub struct BlueprintView {
1604    pub id: String,
1605    pub label: String,
1606    pub output: String,
1607    pub output_qty: u32,
1608    pub craft_ticks: u32,
1609    pub inputs: Vec<BlueprintIngredientView>,
1610    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
1611    #[serde(default)]
1612    pub station: Option<String>,
1613    #[serde(default)]
1614    pub category: Option<String>,
1615    #[serde(default)]
1616    pub required_tools: Vec<ToolRequirementView>,
1617    #[serde(default)]
1618    pub skill: Option<SkillRequirementView>,
1619    #[serde(default)]
1620    pub failure_chance: f32,
1621    /// Copper cost for the employer to teach this recipe to a hired worker.
1622    #[serde(default)]
1623    pub worker_train_copper: u64,
1624}
1625
1626/// Terrain overlay from segment YAML (`terrain_zones`).
1627#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1628#[serde(rename_all = "snake_case")]
1629pub enum TerrainKindView {
1630    #[default]
1631    Grass,
1632    Hill,
1633    Bog,
1634    ShallowWater,
1635    DeepWater,
1636    Trail,
1637    Rock,
1638}
1639
1640#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1641pub struct TerrainZoneView {
1642    pub id: String,
1643    pub x0: f32,
1644    pub y0: f32,
1645    pub x1: f32,
1646    pub y1: f32,
1647    #[serde(default)]
1648    pub kind: TerrainKindView,
1649    /// Ground elevation at this zone (m).
1650    #[serde(default)]
1651    pub elevation: f32,
1652    /// Optional map glyph override (single character); falls back to terrain kind catalog.
1653    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
1654    #[serde(default)]
1655    pub glyph: Option<String>,
1656    /// Optional color (`#RRGGBB` or ratatui name); falls back to kind / elevation tint.
1657    #[serde(default)]
1658    pub color: Option<String>,
1659    /// Optional gfx tile id (`terrain.grass`, …) — presentation only.
1660    #[serde(default)]
1661    pub tile_id: Option<String>,
1662    /// Overlap priority — higher wins (`segment.terrain_zones`).
1663    #[serde(default)]
1664    pub z_order: i32,
1665}
1666
1667/// Walkable platform at a fixed z (`segment.z_platforms`).
1668#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1669pub struct ZPlatformView {
1670    pub id: String,
1671    pub z: f32,
1672    pub x0: f32,
1673    pub y0: f32,
1674    pub x1: f32,
1675    pub y1: f32,
1676}
1677
1678/// Stairs / ramp linking two z bands (`segment.z_transitions`).
1679#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1680pub struct ZTransitionView {
1681    pub id: String,
1682    pub z_from: f32,
1683    pub z_to: f32,
1684    pub x0: f32,
1685    pub y0: f32,
1686    pub x1: f32,
1687    pub y1: f32,
1688}
1689
1690#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1691pub struct BuildingView {
1692    pub id: String,
1693    pub label: String,
1694    pub x: f32,
1695    pub y: f32,
1696    pub width_m: f32,
1697    pub depth_m: f32,
1698    #[serde(default)]
1699    pub interior_blueprint: Option<String>,
1700    #[serde(default)]
1701    pub tags: Vec<String>,
1702}
1703
1704#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1705pub struct DoorView {
1706    pub id: String,
1707    pub building_id: String,
1708    pub x: f32,
1709    pub y: f32,
1710    #[serde(default)]
1711    pub open: bool,
1712    #[serde(default)]
1713    pub portal: Option<String>,
1714}
1715
1716#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1717pub struct InteriorRoomView {
1718    pub id: String,
1719    pub label: String,
1720    pub floor: i32,
1721    pub x0: f32,
1722    pub y0: f32,
1723    pub x1: f32,
1724    pub y1: f32,
1725    #[serde(default)]
1726    pub floor_color: Option<String>,
1727    #[serde(default)]
1728    pub floor_glyph: Option<String>,
1729}
1730
1731#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1732pub struct InteriorDoorView {
1733    pub id: String,
1734    pub room_a: String,
1735    pub room_b: String,
1736    pub x: f32,
1737    pub y: f32,
1738    pub kind: String,
1739}
1740
1741#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1742pub struct InteriorMapView {
1743    pub building_id: String,
1744    pub blueprint_id: String,
1745    pub background_color: String,
1746    #[serde(default)]
1747    pub default_floor_color: Option<String>,
1748    #[serde(default = "default_floor_height_view")]
1749    pub floor_height_m: f32,
1750    pub rooms: Vec<InteriorRoomView>,
1751    #[serde(default)]
1752    pub room_doors: Vec<InteriorDoorView>,
1753}
1754
1755fn default_floor_height_view() -> f32 {
1756    3.0
1757}
1758
1759#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1760pub struct NpcView {
1761    pub id: String,
1762    pub label: String,
1763    pub role: String,
1764    pub x: f32,
1765    pub y: f32,
1766    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
1767    #[serde(default)]
1768    pub building_id: Option<String>,
1769    /// Authoritative sim entity for wildlife / combat targets.
1770    #[serde(default)]
1771    pub entity_id: Option<EntityId>,
1772    #[serde(default)]
1773    pub life_state: Option<LifeState>,
1774    #[serde(default)]
1775    pub hp_pct: Option<f32>,
1776    /// True when this NPC has buy/sell/teach offers (`npc_has_market`).
1777    #[serde(default)]
1778    pub can_trade: bool,
1779    /// Gfx sprite sheet id (`assets/gfx/sprites/`). Client falls back to `npc.{id}` / `npc.{role}`.
1780    #[serde(default)]
1781    pub tile_id: Option<String>,
1782    /// Wildlife FSM state (`idle`, `chase`, `combat`, …) when behavior-driven (debug).
1783    #[serde(default)]
1784    pub behavior_state: Option<String>,
1785    /// Canonical gfx presentation key (`combat`, `pursue`, `walking`, `talking`, …).
1786    #[serde(default)]
1787    pub presentation_state: Option<String>,
1788    /// Resolved gfx sprite mode for `tile_id` (server-computed).
1789    #[serde(default)]
1790    pub sprite_mode: Option<String>,
1791}
1792
1793#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1794pub struct UseResult {
1795    pub template_id: String,
1796    pub hunger_restored: f32,
1797    pub thirst_restored: f32,
1798    #[serde(default)]
1799    pub health_restored: f32,
1800    #[serde(default)]
1801    pub mana_restored: f32,
1802    #[serde(default)]
1803    pub cleared_dot_ids: Vec<String>,
1804}
1805
1806#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1807pub struct CraftResult {
1808    pub blueprint_id: String,
1809    pub outputs: Vec<ItemStack>,
1810    pub consumed: Vec<ItemStack>,
1811    /// 1-based index within the submitted batch (1 when not batching).
1812    #[serde(default = "default_one")]
1813    pub batch_index: u32,
1814    /// Total crafts requested in this batch (1 when not batching).
1815    #[serde(default = "default_one")]
1816    pub batch_total: u32,
1817}
1818
1819fn default_one() -> u32 {
1820    1
1821}
1822
1823#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1824pub struct DeathNotice {
1825    pub entity_id: EntityId,
1826    pub respawn_x: f32,
1827    pub respawn_y: f32,
1828    pub message: String,
1829}
1830
1831#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1832pub struct InteractionNotice {
1833    pub target_id: String,
1834    pub message: String,
1835    #[serde(default)]
1836    pub coins_delta: i32,
1837    #[serde(default)]
1838    pub inventory_delta: Vec<ItemStack>,
1839}
1840
1841#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1842#[serde(rename_all = "snake_case")]
1843pub enum NpcTalkTrustFlag {
1844    Stranger,
1845    Acquainted,
1846    Trusted,
1847}
1848
1849#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1850#[serde(rename_all = "snake_case")]
1851pub enum NpcTalkDepth {
1852    #[default]
1853    Full,
1854    Brief,
1855    Unavailable,
1856}
1857
1858#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1859pub struct NpcTalkOpened {
1860    pub npc_id: String,
1861    pub npc_label: String,
1862    pub greeting: String,
1863    pub trust_flag: NpcTalkTrustFlag,
1864    #[serde(default)]
1865    pub talk_depth: NpcTalkDepth,
1866    #[serde(default = "default_true")]
1867    pub trade_allowed: bool,
1868}
1869
1870fn default_true() -> bool {
1871    true
1872}
1873
1874#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1875pub struct NpcTalkPending {
1876    pub npc_id: String,
1877}
1878
1879#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1880pub struct NpcTalkReply {
1881    pub npc_id: String,
1882    pub line: String,
1883    pub trust_flag: NpcTalkTrustFlag,
1884    #[serde(default)]
1885    pub wind_down: bool,
1886    #[serde(default)]
1887    pub trade_disabled: bool,
1888}
1889
1890#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1891pub struct NpcTalkClosed {
1892    pub npc_id: String,
1893}
1894
1895#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1896pub struct NpcTalkError {
1897    pub npc_id: String,
1898    pub reason: String,
1899}
1900
1901#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1902#[serde(rename_all = "snake_case")]
1903pub enum QuestStatusView {
1904    Available,
1905    Active,
1906    Completed,
1907}
1908
1909#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1910pub struct QuestObjectiveProgress {
1911    pub label: String,
1912    pub current: u32,
1913    pub required: u32,
1914    pub done: bool,
1915}
1916
1917#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1918pub struct QuestLogEntry {
1919    pub quest_id: String,
1920    pub title: String,
1921    pub description: String,
1922    pub status: QuestStatusView,
1923    #[serde(default)]
1924    pub current_step_id: Option<String>,
1925    #[serde(default)]
1926    pub current_step_title: String,
1927    #[serde(default)]
1928    pub objectives: Vec<QuestObjectiveProgress>,
1929    #[serde(default)]
1930    pub is_tracked: bool,
1931    #[serde(default)]
1932    pub can_withdraw: bool,
1933}
1934
1935#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1936pub struct InteractableView {
1937    pub id: String,
1938    pub kind: String,
1939    pub label: String,
1940    pub x: f32,
1941    pub y: f32,
1942    pub z: f32,
1943    #[serde(default)]
1944    pub board_id: Option<String>,
1945}
1946
1947#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1948pub struct QuestOffer {
1949    pub quest_id: String,
1950    pub title: String,
1951    pub description: String,
1952    #[serde(default)]
1953    pub step_count: u32,
1954}
1955
1956#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1957pub struct QuestNotice {
1958    pub quest_id: String,
1959    pub title: String,
1960    pub message: String,
1961}
1962
1963#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1964#[serde(rename_all = "snake_case")]
1965pub enum ShopOfferKind {
1966    Item,
1967    Blueprint,
1968}
1969
1970#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1971pub struct ShopOffer {
1972    pub offer_id: String,
1973    pub kind: ShopOfferKind,
1974    pub label: String,
1975    #[serde(default)]
1976    pub template_id: Option<String>,
1977    #[serde(default)]
1978    pub blueprint_id: Option<String>,
1979    pub price_copper: u32,
1980    #[serde(default)]
1981    pub affordable: bool,
1982    #[serde(default)]
1983    pub already_owned: bool,
1984}
1985
1986#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1987pub struct ShopBuyLine {
1988    pub template_id: String,
1989    pub label: String,
1990    pub quantity: u32,
1991    pub price_copper: u32,
1992}
1993
1994#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1995pub struct ShopCatalog {
1996    pub npc_id: String,
1997    pub npc_label: String,
1998    #[serde(default)]
1999    pub sells: Vec<ShopOffer>,
2000    #[serde(default)]
2001    pub buys: Vec<ShopBuyLine>,
2002}
2003
2004#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2005pub struct HarvestResult {
2006    pub node_id: String,
2007    /// Stack quantity granted (not one-node-one-instance).
2008    pub quantity: u32,
2009    pub item_template: String,
2010    /// Optional DB row id when persisted to control plane.
2011    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
2012    #[serde(default)]
2013    pub item_instance_id: Option<Uuid>,
2014}
2015
2016/// Every on-wire payload is wrapped for versioning and codec uniformity.
2017#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2018pub struct Envelope<T> {
2019    pub protocol_version: u16,
2020    pub payload: T,
2021}
2022
2023impl<T> Envelope<T> {
2024    pub fn new(payload: T) -> Self {
2025        Self {
2026            protocol_version: crate::PROTOCOL_VERSION,
2027            payload,
2028        }
2029    }
2030}
2031
2032/// Session handshake after transport connect.
2033#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2034pub struct Hello {
2035    pub client_name: String,
2036    pub protocol_version: u16,
2037    #[serde(default)]
2038    pub auth: AuthCredential,
2039    /// Required for session auth; embedded in `ApiToken` variant otherwise.
2040    #[serde(default)]
2041    pub character_id: Option<Uuid>,
2042}
2043
2044/// How the client authenticates to the game gateway.
2045/// Uses default serde enum encoding (postcard-compatible; not internally tagged).
2046#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2047#[serde(rename_all = "snake_case")]
2048pub enum AuthCredential {
2049    DevLocal,
2050    Session { token: String },
2051    ApiToken { token: String, character_id: Uuid },
2052}
2053
2054impl Default for AuthCredential {
2055    fn default() -> Self {
2056        Self::DevLocal
2057    }
2058}
2059
2060#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2061pub struct Welcome {
2062    pub session_id: SessionId,
2063    pub entity_id: EntityId,
2064    pub snapshot: Snapshot,
2065}
2066
2067#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2068pub enum ServerMessage {
2069    Welcome(Welcome),
2070    /// Static world layers refreshed (blueprints, catalog, map, terrain) — no client restart.
2071    ContentUpdated(Snapshot),
2072    Tick(TickDelta),
2073    IntentAck {
2074        entity_id: EntityId,
2075        seq: Seq,
2076        tick: Tick,
2077    },
2078    Chat(ChatMessage),
2079    HarvestResult(HarvestResult),
2080    UseResult(UseResult),
2081    CraftResult(CraftResult),
2082    Death(DeathNotice),
2083    Interaction(InteractionNotice),
2084    ShopOpened(ShopCatalog),
2085    NpcTalkOpened(NpcTalkOpened),
2086    NpcTalkPending(NpcTalkPending),
2087    NpcTalkReply(NpcTalkReply),
2088    NpcTalkClosed(NpcTalkClosed),
2089    NpcTalkError(NpcTalkError),
2090    QuestOffer(QuestOffer),
2091    QuestAccepted(QuestNotice),
2092    QuestWithdrawn(QuestNotice),
2093    QuestStepCompleted(QuestNotice),
2094    QuestCompleted(QuestNotice),
2095}
2096
2097#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2098pub enum ClientMessage {
2099    Hello(Hello),
2100    Intent(Intent),
2101    Disconnect,
2102}
2103
2104#[cfg(test)]
2105mod tests {
2106    use super::*;
2107
2108    #[test]
2109    fn pristine_vitals_state_yields_full_pools() {
2110        let attrs = PrimaryAttributes::default();
2111        let vitals = StoredVitalsState::default().apply_to(attrs);
2112        assert!(vitals.health > 0.0);
2113        assert_eq!(vitals.health, vitals.health_max);
2114        assert!((vitals.mana_max - 61.0).abs() < 0.01);
2115    }
2116
2117    #[test]
2118    fn saved_vitals_scale_when_pool_max_increases() {
2119        let mut attrs = PrimaryAttributes::default();
2120        attrs.intelligence = 140;
2121        attrs.wisdom = 140;
2122        let saved = StoredVitalsState {
2123            health: 100.0,
2124            mana: 14.0,
2125            stamina: 100.0,
2126            ..StoredVitalsState::default()
2127        };
2128        let vitals = saved.apply_to(attrs);
2129        assert!(vitals.mana_max > 55.0);
2130        assert!(
2131            (vitals.mana - vitals.mana_max).abs() < 0.01,
2132            "full legacy mana bar migrates to full new bar"
2133        );
2134    }
2135
2136    #[test]
2137    fn empty_vitals_state_is_pristine() {
2138        let pristine = StoredVitalsState {
2139            health: 0.0,
2140            mana: 0.0,
2141            stamina: 0.0,
2142            hunger: 0.0,
2143            thirst: 0.0,
2144            coins: 0,
2145            deaths: 0,
2146            life_state: LifeState::Alive,
2147        };
2148        assert!(pristine.is_pristine());
2149        let vitals = pristine.apply_to(PrimaryAttributes::default());
2150        assert!(vitals.health > 0.0);
2151    }
2152
2153    #[test]
2154    fn stored_vitals_roundtrip_preserves_partial_pools() {
2155        let attrs = PrimaryAttributes::default();
2156        let mut live = PlayerVitals::from_attributes(attrs);
2157        live.health = 25.0;
2158        live.hunger = 77.0;
2159        live.deaths = 2;
2160        let stored = StoredVitalsState::from_live(&live);
2161        let restored = stored.apply_to(attrs);
2162        assert!(
2163            (restored.health - 25.0).abs() < 0.01,
2164            "partial HP below cap stays absolute"
2165        );
2166        assert_eq!(restored.hunger, 77.0);
2167        assert_eq!(restored.deaths, 2);
2168    }
2169
2170    #[test]
2171    fn skill_tiers_start_at_zero() {
2172        let skill = SkillProgress::default();
2173        assert_eq!(skill.level, 0);
2174        assert_eq!(skill.display_tier(), 0);
2175        let trained = SkillProgress {
2176            level: 250,
2177            last_trained_tick: 1,
2178        };
2179        assert_eq!(trained.display_tier(), 2);
2180    }
2181
2182    #[test]
2183    fn quest_server_messages_roundtrip_json() {
2184        use crate::codec::{Codec, PostcardCodec};
2185
2186        let offer = ServerMessage::QuestOffer(QuestOffer {
2187            quest_id: "ada_goblin_hunt".into(),
2188            title: "Goblin Trouble".into(),
2189            description: "Help Ada".into(),
2190            step_count: 3,
2191        });
2192        let notice = ServerMessage::QuestAccepted(QuestNotice {
2193            quest_id: "ada_goblin_hunt".into(),
2194            title: "Goblin Trouble".into(),
2195            message: "Quest accepted".into(),
2196        });
2197        for msg in [offer, notice] {
2198            let bytes = PostcardCodec.encode(&msg).unwrap();
2199            let decoded: ServerMessage = PostcardCodec.decode(&bytes).unwrap();
2200            assert_eq!(decoded, msg);
2201        }
2202    }
2203}