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
435                && new_max > legacy_max * 1.05
436                && current >= legacy_max * 0.95
437            {
438                let ratio = (current / legacy_max).clamp(0.0, 1.0);
439                (new_max * ratio).min(new_max)
440            } else {
441                current.min(new_max)
442            }
443        };
444
445        let mut v = fresh;
446        v.health = scale(self.health, legacy_hp, fresh.health_max);
447        v.mana = scale(self.mana, legacy_mana, fresh.mana_max);
448        v.stamina = scale(self.stamina, legacy_sta, fresh.stamina_max);
449        v.hunger = self.hunger.min(v.hunger_max);
450        v.thirst = self.thirst.min(v.thirst_max);
451        v.coins = self.coins;
452        v.deaths = self.deaths;
453        v.life_state = self.life_state;
454        v
455    }
456}
457
458impl Default for StoredVitalsState {
459    fn default() -> Self {
460        Self::from_live(&PlayerVitals::default())
461    }
462}
463
464/// Saved ability rotation preset (`plans/26` §C3).
465#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
466pub struct RotationPreset {
467    pub id: String,
468    pub label: String,
469    #[serde(default)]
470    pub abilities: Vec<String>,
471}
472
473impl RotationPreset {
474    pub fn melee_default(ability_id: impl Into<String>) -> Self {
475        let id = ability_id.into();
476        Self {
477            id: "melee".into(),
478            label: "Melee".into(),
479            abilities: vec![id],
480        }
481    }
482}
483
484/// Durable combat state (`plans/12` §4.1) — target by wildlife instance id, not entity id.
485#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
486pub struct StoredTargetSlot {
487    pub instance_id: Option<String>,
488    #[serde(default)]
489    pub preset_id: Option<String>,
490    #[serde(default)]
491    pub rotation_index: u32,
492    #[serde(default)]
493    pub auto_enabled: bool,
494}
495
496#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
497#[serde(default)]
498pub struct StoredCombatProfile {
499    /// Wildlife `instance_id` (e.g. `meadow-rabbits-0`), stable across worker restarts.
500    pub combat_target_instance_id: Option<String>,
501    pub in_combat: bool,
502    pub last_combat_tick: u64,
503    pub last_attack_tick: u64,
504    pub cooldowns_until_tick: BTreeMap<String, u64>,
505    #[serde(default = "default_auto_attack")]
506    pub auto_attack_enabled: bool,
507    /// Equipped mainhand weapon template id (`plans/26` §C2b).
508    #[serde(default)]
509    pub mainhand_template_id: Option<String>,
510    /// Equipped body-slot items (backpack, belt, future armor), nested contents included
511    /// (e.g. pouches clipped onto a worn belt). At most one entry per `BodySlot`.
512    #[serde(default)]
513    pub worn: Vec<(BodySlot, ItemStack)>,
514    /// Player-authored rotation library (`plans/26` §C3).
515    #[serde(default)]
516    pub rotation_presets: Vec<RotationPreset>,
517    /// Per-target slot state (T1/T2).
518    #[serde(default)]
519    pub target_slots: Vec<StoredTargetSlot>,
520    /// Discovered craft recipe ids (`plans/09` §C2 hybrid discovery).
521    #[serde(default)]
522    pub known_blueprint_ids: Vec<String>,
523    /// Keys stowed on the keychain (not counted toward carry mass).
524    #[serde(default)]
525    pub keychain: Vec<ItemStack>,
526}
527
528fn default_auto_attack() -> bool {
529    true
530}
531
532impl Default for StoredCombatProfile {
533    fn default() -> Self {
534        Self {
535            combat_target_instance_id: None,
536            in_combat: false,
537            last_combat_tick: 0,
538            last_attack_tick: 0,
539            cooldowns_until_tick: BTreeMap::new(),
540            auto_attack_enabled: true,
541            mainhand_template_id: None,
542            worn: Vec::new(),
543            rotation_presets: Vec::new(),
544            target_slots: Vec::new(),
545            known_blueprint_ids: Vec::new(),
546            keychain: Vec::new(),
547        }
548    }
549}
550
551#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
552pub struct EntityState {
553    pub id: EntityId,
554    pub transform: Transform,
555    /// Character / display name (first letter used as map glyph for other players).
556    #[serde(default)]
557    pub label: String,
558    #[serde(default)]
559    pub vitals: Option<PlayerVitals>,
560    /// Primary attributes (local player / inspect).
561    #[serde(default)]
562    pub attributes: Option<PrimaryAttributes>,
563    #[serde(default)]
564    pub skills: Option<PlayerSkills>,
565    /// When inside a building interior (players only).
566    #[serde(default)]
567    pub inside_building: Option<String>,
568    /// Gfx sprite sheet id for player avatars (`player.hero`, …).
569    #[serde(default)]
570    pub tile_id: Option<String>,
571    /// Canonical gfx presentation key (`walking`, `combat`, `harvesting`, …).
572    #[serde(default)]
573    pub presentation_state: Option<String>,
574    /// Resolved gfx sprite mode for `tile_id` (server-computed).
575    #[serde(default)]
576    pub sprite_mode: Option<String>,
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    /// Block — frontal mitigation while held (`plans/24` §4.3c).
754    Block {
755        entity_id: EntityId,
756        #[serde(default = "default_block_enabled")]
757        enabled: bool,
758        seq: Seq,
759    },
760    /// Equip or clear mainhand weapon (`plans/26` §C2b). `None` unequips.
761    EquipMainhand {
762        entity_id: EntityId,
763        #[serde(default)]
764        template_id: Option<String>,
765        seq: Seq,
766    },
767    /// Equip a wearable (container, belt, or armor piece) to a body slot, or clear the
768    /// slot when `instance_id` is None.
769    EquipWorn {
770        entity_id: EntityId,
771        slot: BodySlot,
772        #[serde(default)]
773        instance_id: Option<Uuid>,
774        seq: Seq,
775    },
776    /// Move an item instance between root / worn / placed container inventories.
777    MoveItem {
778        entity_id: EntityId,
779        item_instance_id: Uuid,
780        from: InventoryLocation,
781        to: InventoryLocation,
782        /// When moving into a container location, nest under this parent instance (None = container root).
783        #[serde(default)]
784        to_parent_instance_id: Option<Uuid>,
785        /// Units to move; `None` moves the whole stack. Server clamps to volume/carry limits.
786        #[serde(default)]
787        quantity: Option<u32>,
788        seq: Seq,
789    },
790    /// Place a placeable container from inventory onto the ground at the player's feet.
791    PlaceContainer {
792        entity_id: EntityId,
793        item_instance_id: Uuid,
794        seq: Seq,
795    },
796    /// Pick up a placed container (with contents) into inventory.
797    PickupContainer {
798        entity_id: EntityId,
799        container_id: String,
800        seq: Seq,
801    },
802    /// Lock or unlock a worn or placed container (requires matching key when locking/unlocking).
803    SetContainerLocked {
804        entity_id: EntityId,
805        /// Worn slot or placed container id encoded as location.
806        location: InventoryLocation,
807        locked: bool,
808        seq: Seq,
809    },
810    /// Drop a non-container item on the ground at the player's feet.
811    DropItem {
812        entity_id: EntityId,
813        item_instance_id: Uuid,
814        from: InventoryLocation,
815        seq: Seq,
816    },
817    /// Permanently destroy an item stack (not recoverable; no ground drop).
818    DestroyItem {
819        entity_id: EntityId,
820        item_instance_id: Uuid,
821        from: InventoryLocation,
822        /// Units to destroy; `None` destroys the whole stack.
823        #[serde(default)]
824        quantity: Option<u32>,
825        seq: Seq,
826    },
827    /// Set a custom display name on a container instance (chest, pouch, backpack).
828    RenameContainer {
829        entity_id: EntityId,
830        item_instance_id: Uuid,
831        location: InventoryLocation,
832        name: String,
833        seq: Seq,
834    },
835    /// Create or update a rotation preset in the player's library.
836    UpsertRotationPreset {
837        entity_id: EntityId,
838        preset: RotationPreset,
839        seq: Seq,
840    },
841    /// Remove a rotation preset from the library.
842    DeleteRotationPreset {
843        entity_id: EntityId,
844        preset_id: String,
845        seq: Seq,
846    },
847    /// Assign a library preset to target slot T1/T2.
848    AssignSlotPreset {
849        entity_id: EntityId,
850        slot_index: u8,
851        preset_id: String,
852        seq: Seq,
853    },
854    /// Fire the next ready ability in a slot's rotation (manual step).
855    AdvanceRotation {
856        entity_id: EntityId,
857        slot_index: u8,
858        seq: Seq,
859    },
860    /// Open a turn-based conversation with an NPC.
861    NpcTalkOpen {
862        entity_id: EntityId,
863        npc_id: String,
864        seq: Seq,
865    },
866    /// Send a player message in an open NPC conversation.
867    NpcTalkSay {
868        entity_id: EntityId,
869        npc_id: String,
870        message: String,
871        seq: Seq,
872    },
873    /// Close an NPC conversation.
874    NpcTalkClose {
875        entity_id: EntityId,
876        npc_id: String,
877        seq: Seq,
878    },
879    /// Accept a discovered quest (adds to active list).
880    AcceptQuest {
881        entity_id: EntityId,
882        quest_id: String,
883        seq: Seq,
884    },
885    /// Withdraw from an active quest (resets progress; can re-accept).
886    WithdrawQuest {
887        entity_id: EntityId,
888        quest_id: String,
889        seq: Seq,
890    },
891    /// Highlight an active quest in the HUD.
892    TrackQuest {
893        entity_id: EntityId,
894        quest_id: String,
895        seq: Seq,
896    },
897    /// Turn in items for an active give_item objective while near an NPC.
898    QuestGiveItem {
899        entity_id: EntityId,
900        npc_id: String,
901        template_id: String,
902        #[serde(default = "default_one")]
903        quantity: u32,
904        seq: Seq,
905    },
906}
907
908fn default_block_enabled() -> bool {
909    true
910}
911
912/// Observer combat HUD (local player only).
913#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
914pub struct CombatTargetHud {
915    pub entity_id: EntityId,
916    #[serde(default)]
917    pub label: String,
918    #[serde(default)]
919    pub level: u32,
920    pub health: f32,
921    pub health_max: f32,
922    #[serde(default)]
923    pub life_state: LifeState,
924    #[serde(default)]
925    pub distance_m: f32,
926}
927
928/// Active spell cast channel progress.
929#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
930pub struct CastProgressHud {
931    #[serde(default)]
932    pub ability_id: String,
933    #[serde(default)]
934    pub ability_label: String,
935    #[serde(default)]
936    pub ticks_remaining: u64,
937    #[serde(default)]
938    pub ticks_total: u64,
939}
940
941/// Cooldown state for a combat ability shown on the action bar.
942#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
943pub struct AbilityCooldownHud {
944    #[serde(default)]
945    pub ability_id: String,
946    #[serde(default)]
947    pub label: String,
948    #[serde(default)]
949    pub cd_ticks: u64,
950    #[serde(default)]
951    pub cd_total_ticks: u64,
952}
953
954/// One combat target slot in the observer HUD.
955#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
956pub struct CombatSlotHud {
957    pub slot_index: u8,
958    #[serde(default)]
959    pub target_entity_id: Option<EntityId>,
960    #[serde(default)]
961    pub target_label: Option<String>,
962    #[serde(default)]
963    pub target: Option<CombatTargetHud>,
964    #[serde(default)]
965    pub preset_id: Option<String>,
966    #[serde(default)]
967    pub preset_label: Option<String>,
968    #[serde(default)]
969    pub rotation: Vec<String>,
970    #[serde(default)]
971    pub rotation_index: u32,
972    #[serde(default)]
973    pub next_ability_id: Option<String>,
974    #[serde(default)]
975    pub auto_enabled: bool,
976}
977
978/// Observer combat HUD (local player only).
979#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
980pub struct CombatHud {
981    pub in_combat: bool,
982    /// T1 auto rotation (legacy field mirrors slot 1).
983    pub auto_attack: bool,
984    pub has_los: bool,
985    pub attack_cd_ticks: u64,
986    #[serde(default)]
987    pub ability_id: String,
988    #[serde(default)]
989    pub target_entity_id: Option<EntityId>,
990    #[serde(default)]
991    pub target_label: Option<String>,
992    #[serde(default)]
993    pub max_target_slots: u8,
994    #[serde(default)]
995    pub slots: Vec<CombatSlotHud>,
996    #[serde(default)]
997    pub rotation_presets: Vec<RotationPreset>,
998    #[serde(default)]
999    pub gcd_ticks: u64,
1000    #[serde(default)]
1001    pub mainhand_template_id: Option<String>,
1002    #[serde(default)]
1003    pub mainhand_label: Option<String>,
1004    /// Equipped body-slot items (backpack, belt w/ clipped pouches, future armor).
1005    #[serde(default)]
1006    pub worn: Vec<(BodySlot, ItemStack)>,
1007    #[serde(default)]
1008    pub carry_mass: f32,
1009    #[serde(default)]
1010    pub carry_mass_max: f32,
1011    #[serde(default)]
1012    pub encumbrance: EncumbranceState,
1013    /// Keys on the virtual keychain (stowed, zero carry mass).
1014    #[serde(default)]
1015    pub keychain: Vec<ItemStack>,
1016    #[serde(default)]
1017    pub target: Option<CombatTargetHud>,
1018    #[serde(default)]
1019    pub cast: Option<CastProgressHud>,
1020    #[serde(default)]
1021    pub ability_cooldowns: Vec<AbilityCooldownHud>,
1022    #[serde(default)]
1023    pub blocking_active: bool,
1024}
1025
1026/// Server → client AOI-filtered entity updates for one sim tick.
1027#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1028pub struct TickDelta {
1029    pub tick: Tick,
1030    pub entities: Vec<EntityState>,
1031    #[serde(default)]
1032    pub resource_nodes: Vec<ResourceNodeView>,
1033    #[serde(default)]
1034    pub buildings: Vec<BuildingView>,
1035    #[serde(default)]
1036    pub doors: Vec<DoorView>,
1037    #[serde(default)]
1038    pub npcs: Vec<NpcView>,
1039    /// Observer inventory stacks (template → qty).
1040    #[serde(default)]
1041    pub inventory: Vec<ItemStack>,
1042    #[serde(default)]
1043    pub blueprints: Vec<BlueprintView>,
1044    #[serde(default)]
1045    pub world_clock: WorldClock,
1046    #[serde(default)]
1047    pub ground_drops: Vec<GroundDropView>,
1048    #[serde(default)]
1049    pub placed_containers: Vec<PlacedContainerView>,
1050    #[serde(default)]
1051    pub combat: Option<CombatHud>,
1052    #[serde(default)]
1053    pub interior_map: Option<InteriorMapView>,
1054    #[serde(default)]
1055    pub quest_log: Vec<QuestLogEntry>,
1056    #[serde(default)]
1057    pub interactables: Vec<InteractableView>,
1058}
1059#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1060pub struct GroundDropView {
1061    pub id: String,
1062    pub template_id: String,
1063    pub quantity: u32,
1064    pub x: f32,
1065    pub y: f32,
1066    pub z: f32,
1067    /// Optional gfx tile id from the item template.
1068    #[serde(default)]
1069    pub tile_id: Option<String>,
1070}
1071
1072/// Full state on region enter or reconnect.
1073#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1074pub struct Snapshot {
1075    pub tick: Tick,
1076    pub chunk_rev: u64,
1077    /// Bumped when blueprints, catalog, segment, or settings reload.
1078    #[serde(default)]
1079    pub content_rev: u64,
1080    pub entities: Vec<EntityState>,
1081    #[serde(default)]
1082    pub resource_nodes: Vec<ResourceNodeView>,
1083    /// Segment play area width in meters (for HUD).
1084    #[serde(default)]
1085    pub world_width_m: f32,
1086    #[serde(default)]
1087    pub world_height_m: f32,
1088    #[serde(default)]
1089    pub buildings: Vec<BuildingView>,
1090    #[serde(default)]
1091    pub doors: Vec<DoorView>,
1092    #[serde(default)]
1093    pub npcs: Vec<NpcView>,
1094    #[serde(default)]
1095    pub inventory: Vec<ItemStack>,
1096    #[serde(default)]
1097    pub blueprints: Vec<BlueprintView>,
1098    #[serde(default)]
1099    pub world_clock: WorldClock,
1100    #[serde(default)]
1101    pub terrain_zones: Vec<TerrainZoneView>,
1102    #[serde(default)]
1103    pub z_platforms: Vec<ZPlatformView>,
1104    #[serde(default)]
1105    pub z_transitions: Vec<ZTransitionView>,
1106    #[serde(default)]
1107    pub ground_drops: Vec<GroundDropView>,
1108    #[serde(default)]
1109    pub placed_containers: Vec<PlacedContainerView>,
1110    #[serde(default)]
1111    pub combat: Option<CombatHud>,
1112    #[serde(default)]
1113    pub interior_map: Option<InteriorMapView>,
1114    #[serde(default)]
1115    pub quest_log: Vec<QuestLogEntry>,
1116    #[serde(default)]
1117    pub interactables: Vec<InteractableView>,
1118}
1119
1120/// Harvestable node visible to clients.
1121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1122pub struct ResourceNodeView {
1123    pub id: String,
1124    pub label: String,
1125    pub x: f32,
1126    pub y: f32,
1127    pub z: f32,
1128    pub item_template: String,
1129    #[serde(default = "default_node_state")]
1130    pub state: ResourceNodeState,
1131    /// When true, players cannot walk through this node while available/harvesting.
1132    #[serde(default = "default_blocking_view")]
1133    pub blocking: bool,
1134    /// Collision radius for pathfinding (meters).
1135    #[serde(default = "default_blocking_radius_view")]
1136    pub blocking_radius_m: f32,
1137    /// Optional gfx tile id (`resource.oak_log`, …).
1138    #[serde(default)]
1139    pub tile_id: Option<String>,
1140    /// Resolved gfx sprite mode for `tile_id` (server-computed).
1141    #[serde(default)]
1142    pub sprite_mode: Option<String>,
1143    /// Canonical presentation key (`available`, `harvesting`, `depleted`).
1144    #[serde(default)]
1145    pub presentation_state: Option<String>,
1146}
1147
1148fn default_blocking_radius_view() -> f32 {
1149    0.8
1150}
1151
1152fn default_blocking_view() -> bool {
1153    true
1154}
1155
1156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1157#[serde(rename_all = "snake_case")]
1158pub enum ResourceNodeState {
1159    Available,
1160    Harvesting,
1161    Cooldown,
1162}
1163
1164fn default_node_state() -> ResourceNodeState {
1165    ResourceNodeState::Available
1166}
1167
1168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1169pub struct ItemStack {
1170    pub template_id: String,
1171    pub quantity: u32,
1172    /// Stable instance id — preserved across checkpoint/sync when set.
1173    #[serde(default)]
1174    pub item_instance_id: Option<Uuid>,
1175    /// Per-instance metadata (stat rolls, soul-bind, lock ids, etc.).
1176    #[serde(default)]
1177    pub props: BTreeMap<String, String>,
1178    /// Nested contents when this stack is a container instance.
1179    #[serde(default)]
1180    pub contents: Vec<ItemStack>,
1181    /// From item catalog when sent on wire (display only).
1182    #[serde(default)]
1183    pub display_name: Option<String>,
1184    /// From item catalog when sent on wire (`weapon`, `consumable`, …).
1185    #[serde(default)]
1186    pub category: Option<String>,
1187    /// Per-unit mass in kg from catalog (display / move UX).
1188    #[serde(default)]
1189    pub base_mass: Option<f32>,
1190    /// Per-unit volume from catalog (display / move UX).
1191    #[serde(default)]
1192    pub base_volume: Option<f32>,
1193    /// Container capacity when this stack is a container template.
1194    #[serde(default)]
1195    pub capacity_volume: Option<f32>,
1196    /// Whether the template stacks in inventory (from catalog).
1197    #[serde(default)]
1198    pub stackable: Option<bool>,
1199}
1200
1201impl ItemStack {
1202    pub fn simple(template_id: impl Into<String>, quantity: u32) -> Self {
1203        Self {
1204            template_id: template_id.into(),
1205            quantity,
1206            item_instance_id: None,
1207            props: BTreeMap::new(),
1208            contents: Vec::new(),
1209            display_name: None,
1210            category: None,
1211            base_mass: None,
1212            base_volume: None,
1213            capacity_volume: None,
1214            stackable: None,
1215        }
1216    }
1217}
1218
1219/// Carry encumbrance band (`plans/08` §3.2).
1220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1221#[serde(rename_all = "snake_case")]
1222pub enum EncumbranceState {
1223    #[default]
1224    Light,
1225    Heavy,
1226    Over,
1227}
1228
1229/// Body region a wearable item occupies — at most one item equipped per slot.
1230/// `Head`/`Body`/`Arms`/`Legs`/`Feet` are reserved for future armor (`plans/11`); only
1231/// `Back` (backpack) and `Waist` (belt) have equippable items today (`plans/08` §4.1).
1232#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)]
1233#[serde(rename_all = "snake_case")]
1234pub enum BodySlot {
1235    Head,
1236    Body,
1237    Arms,
1238    Legs,
1239    Feet,
1240    Back,
1241    Waist,
1242}
1243
1244/// Where an item lives for `MoveItem` / open-container UX.
1245#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1246#[serde(rename_all = "snake_case")]
1247pub enum InventoryLocation {
1248    /// Loose on-person inventory (not inside a worn/placed container).
1249    Root,
1250    /// Inside a worn item (backpack contents, or a pouch clipped onto a worn belt).
1251    Worn {
1252        slot: BodySlot,
1253    },
1254    /// Inside a world-placed container.
1255    Placed {
1256        container_id: String,
1257    },
1258    /// Virtual key ring — only `container_key` items; zero carry mass; persists with combat profile.
1259    Keychain,
1260}
1261
1262/// AOI view of a placeable chest on the map.
1263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1264pub struct PlacedContainerView {
1265    pub id: String,
1266    pub template_id: String,
1267    pub display_name: String,
1268    pub x: f32,
1269    pub y: f32,
1270    pub z: f32,
1271    pub locked: bool,
1272    /// Observer can open (unlocked, or holds matching key).
1273    #[serde(default)]
1274    pub accessible: bool,
1275    #[serde(default)]
1276    pub owner_character_id: Option<Uuid>,
1277    /// Nested contents when `accessible` (empty when locked without key).
1278    #[serde(default)]
1279    pub contents: Vec<ItemStack>,
1280    /// Lock id for matching `container_key` props (`opens_lock_id`).
1281    #[serde(default)]
1282    pub lock_id: Option<String>,
1283    /// Internal storage capacity (liters) from item catalog.
1284    #[serde(default)]
1285    pub capacity_volume: Option<f32>,
1286    /// Container instance id (for MoveItem parent targeting).
1287    #[serde(default)]
1288    pub item_instance_id: Option<Uuid>,
1289    /// Optional gfx tile id from the item template.
1290    #[serde(default)]
1291    pub tile_id: Option<String>,
1292}
1293
1294#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1295pub struct BlueprintIngredientView {
1296    pub template_id: String,
1297    pub quantity: u32,
1298    /// Always serialize — `true` is not bool::default() so postcard keeps it; explicit for clarity.
1299    pub consumed: bool,
1300}
1301
1302#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1303pub struct ToolRequirementView {
1304    pub item: String,
1305    /// Always serialize — postcard omits `false` by default, which breaks roundtrip without explicit value.
1306    pub consumed: bool,
1307}
1308
1309#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1310pub struct SkillRequirementView {
1311    pub skill: String,
1312    pub level: u32,
1313}
1314
1315#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1316pub struct BlueprintView {
1317    pub id: String,
1318    pub label: String,
1319    pub output: String,
1320    pub output_qty: u32,
1321    pub craft_ticks: u32,
1322    pub inputs: Vec<BlueprintIngredientView>,
1323    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
1324    #[serde(default)]
1325    pub station: Option<String>,
1326    #[serde(default)]
1327    pub category: Option<String>,
1328    #[serde(default)]
1329    pub required_tools: Vec<ToolRequirementView>,
1330    #[serde(default)]
1331    pub skill: Option<SkillRequirementView>,
1332    #[serde(default)]
1333    pub failure_chance: f32,
1334}
1335
1336/// Terrain overlay from segment YAML (`terrain_zones`).
1337#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1338#[serde(rename_all = "snake_case")]
1339pub enum TerrainKindView {
1340    #[default]
1341    Grass,
1342    Hill,
1343    Bog,
1344    ShallowWater,
1345    DeepWater,
1346    Trail,
1347    Rock,
1348}
1349
1350#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1351pub struct TerrainZoneView {
1352    pub id: String,
1353    pub x0: f32,
1354    pub y0: f32,
1355    pub x1: f32,
1356    pub y1: f32,
1357    #[serde(default)]
1358    pub kind: TerrainKindView,
1359    /// Ground elevation at this zone (m).
1360    #[serde(default)]
1361    pub elevation: f32,
1362    /// Optional map glyph override (single character); falls back to terrain kind catalog.
1363    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
1364    #[serde(default)]
1365    pub glyph: Option<String>,
1366    /// Optional color (`#RRGGBB` or ratatui name); falls back to kind / elevation tint.
1367    #[serde(default)]
1368    pub color: Option<String>,
1369    /// Optional gfx tile id (`terrain.grass`, …) — presentation only.
1370    #[serde(default)]
1371    pub tile_id: Option<String>,
1372}
1373
1374/// Walkable platform at a fixed z (`segment.z_platforms`).
1375#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1376pub struct ZPlatformView {
1377    pub id: String,
1378    pub z: f32,
1379    pub x0: f32,
1380    pub y0: f32,
1381    pub x1: f32,
1382    pub y1: f32,
1383}
1384
1385/// Stairs / ramp linking two z bands (`segment.z_transitions`).
1386#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1387pub struct ZTransitionView {
1388    pub id: String,
1389    pub z_from: f32,
1390    pub z_to: f32,
1391    pub x0: f32,
1392    pub y0: f32,
1393    pub x1: f32,
1394    pub y1: f32,
1395}
1396
1397#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1398pub struct BuildingView {
1399    pub id: String,
1400    pub label: String,
1401    pub x: f32,
1402    pub y: f32,
1403    pub width_m: f32,
1404    pub depth_m: f32,
1405    #[serde(default)]
1406    pub interior_blueprint: Option<String>,
1407    #[serde(default)]
1408    pub tags: Vec<String>,
1409}
1410
1411#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1412pub struct DoorView {
1413    pub id: String,
1414    pub building_id: String,
1415    pub x: f32,
1416    pub y: f32,
1417    #[serde(default)]
1418    pub open: bool,
1419    #[serde(default)]
1420    pub portal: Option<String>,
1421}
1422
1423#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1424pub struct InteriorRoomView {
1425    pub id: String,
1426    pub label: String,
1427    pub floor: i32,
1428    pub x0: f32,
1429    pub y0: f32,
1430    pub x1: f32,
1431    pub y1: f32,
1432    #[serde(default)]
1433    pub floor_color: Option<String>,
1434    #[serde(default)]
1435    pub floor_glyph: Option<String>,
1436}
1437
1438#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1439pub struct InteriorDoorView {
1440    pub id: String,
1441    pub room_a: String,
1442    pub room_b: String,
1443    pub x: f32,
1444    pub y: f32,
1445    pub kind: String,
1446}
1447
1448#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1449pub struct InteriorMapView {
1450    pub building_id: String,
1451    pub blueprint_id: String,
1452    pub background_color: String,
1453    #[serde(default)]
1454    pub default_floor_color: Option<String>,
1455    #[serde(default = "default_floor_height_view")]
1456    pub floor_height_m: f32,
1457    pub rooms: Vec<InteriorRoomView>,
1458    #[serde(default)]
1459    pub room_doors: Vec<InteriorDoorView>,
1460}
1461
1462fn default_floor_height_view() -> f32 {
1463    3.0
1464}
1465
1466#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1467pub struct NpcView {
1468    pub id: String,
1469    pub label: String,
1470    pub role: String,
1471    pub x: f32,
1472    pub y: f32,
1473    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
1474    #[serde(default)]
1475    pub building_id: Option<String>,
1476    /// Authoritative sim entity for wildlife / combat targets.
1477    #[serde(default)]
1478    pub entity_id: Option<EntityId>,
1479    #[serde(default)]
1480    pub life_state: Option<LifeState>,
1481    #[serde(default)]
1482    pub hp_pct: Option<f32>,
1483    /// True when this NPC has buy/sell/teach offers (`npc_has_market`).
1484    #[serde(default)]
1485    pub can_trade: bool,
1486    /// Gfx sprite sheet id (`assets/gfx/sprites/`). Client falls back to `npc.{id}` / `npc.{role}`.
1487    #[serde(default)]
1488    pub tile_id: Option<String>,
1489    /// Wildlife FSM state (`idle`, `chase`, `combat`, …) when behavior-driven (debug).
1490    #[serde(default)]
1491    pub behavior_state: Option<String>,
1492    /// Canonical gfx presentation key (`combat`, `pursue`, `walking`, `talking`, …).
1493    #[serde(default)]
1494    pub presentation_state: Option<String>,
1495    /// Resolved gfx sprite mode for `tile_id` (server-computed).
1496    #[serde(default)]
1497    pub sprite_mode: Option<String>,
1498}
1499
1500#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1501pub struct UseResult {
1502    pub template_id: String,
1503    pub hunger_restored: f32,
1504    pub thirst_restored: f32,
1505    #[serde(default)]
1506    pub health_restored: f32,
1507    #[serde(default)]
1508    pub mana_restored: f32,
1509    #[serde(default)]
1510    pub cleared_dot_ids: Vec<String>,
1511}
1512
1513#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1514pub struct CraftResult {
1515    pub blueprint_id: String,
1516    pub outputs: Vec<ItemStack>,
1517    pub consumed: Vec<ItemStack>,
1518    /// 1-based index within the submitted batch (1 when not batching).
1519    #[serde(default = "default_one")]
1520    pub batch_index: u32,
1521    /// Total crafts requested in this batch (1 when not batching).
1522    #[serde(default = "default_one")]
1523    pub batch_total: u32,
1524}
1525
1526fn default_one() -> u32 {
1527    1
1528}
1529
1530#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1531pub struct DeathNotice {
1532    pub entity_id: EntityId,
1533    pub respawn_x: f32,
1534    pub respawn_y: f32,
1535    pub message: String,
1536}
1537
1538#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1539pub struct InteractionNotice {
1540    pub target_id: String,
1541    pub message: String,
1542    #[serde(default)]
1543    pub coins_delta: i32,
1544    #[serde(default)]
1545    pub inventory_delta: Vec<ItemStack>,
1546}
1547
1548#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1549#[serde(rename_all = "snake_case")]
1550pub enum NpcTalkTrustFlag {
1551    Stranger,
1552    Acquainted,
1553    Trusted,
1554}
1555
1556#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1557pub struct NpcTalkOpened {
1558    pub npc_id: String,
1559    pub npc_label: String,
1560    pub greeting: String,
1561    pub trust_flag: NpcTalkTrustFlag,
1562}
1563
1564#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1565pub struct NpcTalkPending {
1566    pub npc_id: String,
1567}
1568
1569#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1570pub struct NpcTalkReply {
1571    pub npc_id: String,
1572    pub line: String,
1573    pub trust_flag: NpcTalkTrustFlag,
1574}
1575
1576#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1577pub struct NpcTalkClosed {
1578    pub npc_id: String,
1579}
1580
1581#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1582pub struct NpcTalkError {
1583    pub npc_id: String,
1584    pub reason: String,
1585}
1586
1587#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1588#[serde(rename_all = "snake_case")]
1589pub enum QuestStatusView {
1590    Available,
1591    Active,
1592    Completed,
1593}
1594
1595#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1596pub struct QuestObjectiveProgress {
1597    pub label: String,
1598    pub current: u32,
1599    pub required: u32,
1600    pub done: bool,
1601}
1602
1603#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1604pub struct QuestLogEntry {
1605    pub quest_id: String,
1606    pub title: String,
1607    pub description: String,
1608    pub status: QuestStatusView,
1609    #[serde(default)]
1610    pub current_step_id: Option<String>,
1611    #[serde(default)]
1612    pub current_step_title: String,
1613    #[serde(default)]
1614    pub objectives: Vec<QuestObjectiveProgress>,
1615    #[serde(default)]
1616    pub is_tracked: bool,
1617    #[serde(default)]
1618    pub can_withdraw: bool,
1619}
1620
1621#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1622pub struct InteractableView {
1623    pub id: String,
1624    pub kind: String,
1625    pub label: String,
1626    pub x: f32,
1627    pub y: f32,
1628    pub z: f32,
1629    #[serde(default)]
1630    pub board_id: Option<String>,
1631}
1632
1633#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1634pub struct QuestOffer {
1635    pub quest_id: String,
1636    pub title: String,
1637    pub description: String,
1638    #[serde(default)]
1639    pub step_count: u32,
1640}
1641
1642#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1643pub struct QuestNotice {
1644    pub quest_id: String,
1645    pub title: String,
1646    pub message: String,
1647}
1648
1649#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1650#[serde(rename_all = "snake_case")]
1651pub enum ShopOfferKind {
1652    Item,
1653    Blueprint,
1654}
1655
1656#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1657pub struct ShopOffer {
1658    pub offer_id: String,
1659    pub kind: ShopOfferKind,
1660    pub label: String,
1661    #[serde(default)]
1662    pub template_id: Option<String>,
1663    #[serde(default)]
1664    pub blueprint_id: Option<String>,
1665    pub price_copper: u32,
1666    #[serde(default)]
1667    pub affordable: bool,
1668    #[serde(default)]
1669    pub already_owned: bool,
1670}
1671
1672#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1673pub struct ShopBuyLine {
1674    pub template_id: String,
1675    pub label: String,
1676    pub quantity: u32,
1677    pub price_copper: u32,
1678}
1679
1680#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1681pub struct ShopCatalog {
1682    pub npc_id: String,
1683    pub npc_label: String,
1684    #[serde(default)]
1685    pub sells: Vec<ShopOffer>,
1686    #[serde(default)]
1687    pub buys: Vec<ShopBuyLine>,
1688}
1689
1690#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1691pub struct HarvestResult {
1692    pub node_id: String,
1693    /// Stack quantity granted (not one-node-one-instance).
1694    pub quantity: u32,
1695    pub item_template: String,
1696    /// Optional DB row id when persisted to control plane.
1697    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
1698    #[serde(default)]
1699    pub item_instance_id: Option<Uuid>,
1700}
1701
1702/// Every on-wire payload is wrapped for versioning and codec uniformity.
1703#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1704pub struct Envelope<T> {
1705    pub protocol_version: u16,
1706    pub payload: T,
1707}
1708
1709impl<T> Envelope<T> {
1710    pub fn new(payload: T) -> Self {
1711        Self {
1712            protocol_version: crate::PROTOCOL_VERSION,
1713            payload,
1714        }
1715    }
1716}
1717
1718/// Session handshake after transport connect.
1719#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1720pub struct Hello {
1721    pub client_name: String,
1722    pub protocol_version: u16,
1723    #[serde(default)]
1724    pub auth: AuthCredential,
1725    /// Required for session auth; embedded in `ApiToken` variant otherwise.
1726    #[serde(default)]
1727    pub character_id: Option<Uuid>,
1728}
1729
1730/// How the client authenticates to the game gateway.
1731/// Uses default serde enum encoding (postcard-compatible; not internally tagged).
1732#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1733#[serde(rename_all = "snake_case")]
1734pub enum AuthCredential {
1735    DevLocal,
1736    Session { token: String },
1737    ApiToken { token: String, character_id: Uuid },
1738}
1739
1740impl Default for AuthCredential {
1741    fn default() -> Self {
1742        Self::DevLocal
1743    }
1744}
1745
1746#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1747pub struct Welcome {
1748    pub session_id: SessionId,
1749    pub entity_id: EntityId,
1750    pub snapshot: Snapshot,
1751}
1752
1753#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1754pub enum ServerMessage {
1755    Welcome(Welcome),
1756    /// Static world layers refreshed (blueprints, catalog, map, terrain) — no client restart.
1757    ContentUpdated(Snapshot),
1758    Tick(TickDelta),
1759    IntentAck {
1760        entity_id: EntityId,
1761        seq: Seq,
1762        tick: Tick,
1763    },
1764    Chat(ChatMessage),
1765    HarvestResult(HarvestResult),
1766    UseResult(UseResult),
1767    CraftResult(CraftResult),
1768    Death(DeathNotice),
1769    Interaction(InteractionNotice),
1770    ShopOpened(ShopCatalog),
1771    NpcTalkOpened(NpcTalkOpened),
1772    NpcTalkPending(NpcTalkPending),
1773    NpcTalkReply(NpcTalkReply),
1774    NpcTalkClosed(NpcTalkClosed),
1775    NpcTalkError(NpcTalkError),
1776    QuestOffer(QuestOffer),
1777    QuestAccepted(QuestNotice),
1778    QuestWithdrawn(QuestNotice),
1779    QuestStepCompleted(QuestNotice),
1780    QuestCompleted(QuestNotice),
1781}
1782
1783#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1784pub enum ClientMessage {
1785    Hello(Hello),
1786    Intent(Intent),
1787    Disconnect,
1788}
1789
1790#[cfg(test)]
1791mod tests {
1792    use super::*;
1793
1794    #[test]
1795    fn pristine_vitals_state_yields_full_pools() {
1796        let attrs = PrimaryAttributes::default();
1797        let vitals = StoredVitalsState::default().apply_to(attrs);
1798        assert!(vitals.health > 0.0);
1799        assert_eq!(vitals.health, vitals.health_max);
1800        assert!((vitals.mana_max - 61.0).abs() < 0.01);
1801    }
1802
1803    #[test]
1804    fn saved_vitals_scale_when_pool_max_increases() {
1805        let mut attrs = PrimaryAttributes::default();
1806        attrs.intelligence = 140;
1807        attrs.wisdom = 140;
1808        let saved = StoredVitalsState {
1809            health: 100.0,
1810            mana: 14.0,
1811            stamina: 100.0,
1812            ..StoredVitalsState::default()
1813        };
1814        let vitals = saved.apply_to(attrs);
1815        assert!(vitals.mana_max > 55.0);
1816        assert!(
1817            (vitals.mana - vitals.mana_max).abs() < 0.01,
1818            "full legacy mana bar migrates to full new bar"
1819        );
1820    }
1821
1822    #[test]
1823    fn empty_vitals_state_is_pristine() {
1824        let pristine = StoredVitalsState {
1825            health: 0.0,
1826            mana: 0.0,
1827            stamina: 0.0,
1828            hunger: 0.0,
1829            thirst: 0.0,
1830            coins: 0,
1831            deaths: 0,
1832            life_state: LifeState::Alive,
1833        };
1834        assert!(pristine.is_pristine());
1835        let vitals = pristine.apply_to(PrimaryAttributes::default());
1836        assert!(vitals.health > 0.0);
1837    }
1838
1839    #[test]
1840    fn stored_vitals_roundtrip_preserves_partial_pools() {
1841        let attrs = PrimaryAttributes::default();
1842        let mut live = PlayerVitals::from_attributes(attrs);
1843        live.health = 25.0;
1844        live.hunger = 77.0;
1845        live.deaths = 2;
1846        let stored = StoredVitalsState::from_live(&live);
1847        let restored = stored.apply_to(attrs);
1848        assert!((restored.health - 25.0).abs() < 0.01, "partial HP below cap stays absolute");
1849        assert_eq!(restored.hunger, 77.0);
1850        assert_eq!(restored.deaths, 2);
1851    }
1852
1853    #[test]
1854    fn skill_tiers_start_at_zero() {
1855        let skill = SkillProgress::default();
1856        assert_eq!(skill.level, 0);
1857        assert_eq!(skill.display_tier(), 0);
1858        let trained = SkillProgress {
1859            level: 250,
1860            last_trained_tick: 1,
1861        };
1862        assert_eq!(trained.display_tier(), 2);
1863    }
1864
1865    #[test]
1866    fn quest_server_messages_roundtrip_json() {
1867        use crate::codec::{Codec, PostcardCodec};
1868
1869        let offer = ServerMessage::QuestOffer(QuestOffer {
1870            quest_id: "ada_goblin_hunt".into(),
1871            title: "Goblin Trouble".into(),
1872            description: "Help Ada".into(),
1873            step_count: 3,
1874        });
1875        let notice = ServerMessage::QuestAccepted(QuestNotice {
1876            quest_id: "ada_goblin_hunt".into(),
1877            title: "Goblin Trouble".into(),
1878            message: "Quest accepted".into(),
1879        });
1880        for msg in [offer, notice] {
1881            let bytes = PostcardCodec.encode(&msg).unwrap();
1882            let decoded: ServerMessage = PostcardCodec.decode(&bytes).unwrap();
1883            assert_eq!(decoded, msg);
1884        }
1885    }
1886}