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    /// 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    /// Live XP pools for the local player (updated every tick with combat HUD).
1025    #[serde(default)]
1026    pub progression_xp: Option<ProgressionXp>,
1027    #[serde(default)]
1028    pub progression_baseline: u16,
1029    #[serde(default)]
1030    pub progression_xp_base: f64,
1031    #[serde(default)]
1032    pub progression_xp_growth: f64,
1033    #[serde(default)]
1034    pub attributes: Option<PrimaryAttributes>,
1035    #[serde(default)]
1036    pub skills: Option<PlayerSkills>,
1037}
1038
1039/// Server → client AOI-filtered entity updates for one sim tick.
1040#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1041pub struct TickDelta {
1042    pub tick: Tick,
1043    pub entities: Vec<EntityState>,
1044    #[serde(default)]
1045    pub resource_nodes: Vec<ResourceNodeView>,
1046    #[serde(default)]
1047    pub buildings: Vec<BuildingView>,
1048    #[serde(default)]
1049    pub doors: Vec<DoorView>,
1050    #[serde(default)]
1051    pub npcs: Vec<NpcView>,
1052    /// Observer inventory stacks (template → qty).
1053    #[serde(default)]
1054    pub inventory: Vec<ItemStack>,
1055    #[serde(default)]
1056    pub blueprints: Vec<BlueprintView>,
1057    #[serde(default)]
1058    pub world_clock: WorldClock,
1059    #[serde(default)]
1060    pub ground_drops: Vec<GroundDropView>,
1061    #[serde(default)]
1062    pub placed_containers: Vec<PlacedContainerView>,
1063    #[serde(default)]
1064    pub combat: Option<CombatHud>,
1065    #[serde(default)]
1066    pub interior_map: Option<InteriorMapView>,
1067    #[serde(default)]
1068    pub quest_log: Vec<QuestLogEntry>,
1069    #[serde(default)]
1070    pub interactables: Vec<InteractableView>,
1071}
1072#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1073pub struct GroundDropView {
1074    pub id: String,
1075    pub template_id: String,
1076    pub quantity: u32,
1077    pub x: f32,
1078    pub y: f32,
1079    pub z: f32,
1080    /// Optional gfx tile id from the item template.
1081    #[serde(default)]
1082    pub tile_id: Option<String>,
1083}
1084
1085/// Full state on region enter or reconnect.
1086#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1087pub struct Snapshot {
1088    pub tick: Tick,
1089    pub chunk_rev: u64,
1090    /// Bumped when blueprints, catalog, segment, or settings reload.
1091    #[serde(default)]
1092    pub content_rev: u64,
1093    pub entities: Vec<EntityState>,
1094    #[serde(default)]
1095    pub resource_nodes: Vec<ResourceNodeView>,
1096    /// Segment play area width in meters (for HUD).
1097    #[serde(default)]
1098    pub world_width_m: f32,
1099    #[serde(default)]
1100    pub world_height_m: f32,
1101    #[serde(default)]
1102    pub buildings: Vec<BuildingView>,
1103    #[serde(default)]
1104    pub doors: Vec<DoorView>,
1105    #[serde(default)]
1106    pub npcs: Vec<NpcView>,
1107    #[serde(default)]
1108    pub inventory: Vec<ItemStack>,
1109    #[serde(default)]
1110    pub blueprints: Vec<BlueprintView>,
1111    #[serde(default)]
1112    pub world_clock: WorldClock,
1113    #[serde(default)]
1114    pub terrain_zones: Vec<TerrainZoneView>,
1115    #[serde(default)]
1116    pub z_platforms: Vec<ZPlatformView>,
1117    #[serde(default)]
1118    pub z_transitions: Vec<ZTransitionView>,
1119    #[serde(default)]
1120    pub ground_drops: Vec<GroundDropView>,
1121    #[serde(default)]
1122    pub placed_containers: Vec<PlacedContainerView>,
1123    #[serde(default)]
1124    pub combat: Option<CombatHud>,
1125    #[serde(default)]
1126    pub interior_map: Option<InteriorMapView>,
1127    #[serde(default)]
1128    pub quest_log: Vec<QuestLogEntry>,
1129    #[serde(default)]
1130    pub interactables: Vec<InteractableView>,
1131}
1132
1133/// Harvestable node visible to clients.
1134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1135pub struct ResourceNodeView {
1136    pub id: String,
1137    pub label: String,
1138    pub x: f32,
1139    pub y: f32,
1140    pub z: f32,
1141    pub item_template: String,
1142    #[serde(default = "default_node_state")]
1143    pub state: ResourceNodeState,
1144    /// When true, players cannot walk through this node while available/harvesting.
1145    #[serde(default = "default_blocking_view")]
1146    pub blocking: bool,
1147    /// Collision radius for pathfinding (meters).
1148    #[serde(default = "default_blocking_radius_view")]
1149    pub blocking_radius_m: f32,
1150    /// Optional gfx tile id (`resource.oak_log`, …).
1151    #[serde(default)]
1152    pub tile_id: Option<String>,
1153    /// Resolved gfx sprite mode for `tile_id` (server-computed).
1154    #[serde(default)]
1155    pub sprite_mode: Option<String>,
1156    /// Canonical presentation key (`available`, `harvesting`, `depleted`).
1157    #[serde(default)]
1158    pub presentation_state: Option<String>,
1159}
1160
1161fn default_blocking_radius_view() -> f32 {
1162    0.8
1163}
1164
1165fn default_blocking_view() -> bool {
1166    true
1167}
1168
1169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1170#[serde(rename_all = "snake_case")]
1171pub enum ResourceNodeState {
1172    Available,
1173    Harvesting,
1174    Cooldown,
1175}
1176
1177fn default_node_state() -> ResourceNodeState {
1178    ResourceNodeState::Available
1179}
1180
1181#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1182pub struct ItemStack {
1183    pub template_id: String,
1184    pub quantity: u32,
1185    /// Stable instance id — preserved across checkpoint/sync when set.
1186    #[serde(default)]
1187    pub item_instance_id: Option<Uuid>,
1188    /// Per-instance metadata (stat rolls, soul-bind, lock ids, etc.).
1189    #[serde(default)]
1190    pub props: BTreeMap<String, String>,
1191    /// Nested contents when this stack is a container instance.
1192    #[serde(default)]
1193    pub contents: Vec<ItemStack>,
1194    /// From item catalog when sent on wire (display only).
1195    #[serde(default)]
1196    pub display_name: Option<String>,
1197    /// From item catalog when sent on wire (`weapon`, `consumable`, …).
1198    #[serde(default)]
1199    pub category: Option<String>,
1200    /// Per-unit mass in kg from catalog (display / move UX).
1201    #[serde(default)]
1202    pub base_mass: Option<f32>,
1203    /// Per-unit volume from catalog (display / move UX).
1204    #[serde(default)]
1205    pub base_volume: Option<f32>,
1206    /// Container capacity when this stack is a container template.
1207    #[serde(default)]
1208    pub capacity_volume: Option<f32>,
1209    /// Whether the template stacks in inventory (from catalog).
1210    #[serde(default)]
1211    pub stackable: Option<bool>,
1212}
1213
1214impl ItemStack {
1215    pub fn simple(template_id: impl Into<String>, quantity: u32) -> Self {
1216        Self {
1217            template_id: template_id.into(),
1218            quantity,
1219            item_instance_id: None,
1220            props: BTreeMap::new(),
1221            contents: Vec::new(),
1222            display_name: None,
1223            category: None,
1224            base_mass: None,
1225            base_volume: None,
1226            capacity_volume: None,
1227            stackable: None,
1228        }
1229    }
1230}
1231
1232/// Carry encumbrance band (`plans/08` §3.2).
1233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1234#[serde(rename_all = "snake_case")]
1235pub enum EncumbranceState {
1236    #[default]
1237    Light,
1238    Heavy,
1239    Over,
1240}
1241
1242/// Body region a wearable item occupies — at most one item equipped per slot.
1243/// `Head`/`Body`/`Arms`/`Legs`/`Feet` are reserved for future armor (`plans/11`); only
1244/// `Back` (backpack) and `Waist` (belt) have equippable items today (`plans/08` §4.1).
1245#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)]
1246#[serde(rename_all = "snake_case")]
1247pub enum BodySlot {
1248    Head,
1249    Body,
1250    Arms,
1251    Legs,
1252    Feet,
1253    Back,
1254    Waist,
1255}
1256
1257/// Where an item lives for `MoveItem` / open-container UX.
1258#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1259#[serde(rename_all = "snake_case")]
1260pub enum InventoryLocation {
1261    /// Loose on-person inventory (not inside a worn/placed container).
1262    Root,
1263    /// Inside a worn item (backpack contents, or a pouch clipped onto a worn belt).
1264    Worn { slot: BodySlot },
1265    /// Inside a world-placed container.
1266    Placed { container_id: String },
1267    /// Virtual key ring — only `container_key` items; zero carry mass; persists with combat profile.
1268    Keychain,
1269}
1270
1271/// AOI view of a placeable chest on the map.
1272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1273pub struct PlacedContainerView {
1274    pub id: String,
1275    pub template_id: String,
1276    pub display_name: String,
1277    pub x: f32,
1278    pub y: f32,
1279    pub z: f32,
1280    pub locked: bool,
1281    /// Observer can open (unlocked, or holds matching key).
1282    #[serde(default)]
1283    pub accessible: bool,
1284    #[serde(default)]
1285    pub owner_character_id: Option<Uuid>,
1286    /// Nested contents when `accessible` (empty when locked without key).
1287    #[serde(default)]
1288    pub contents: Vec<ItemStack>,
1289    /// Lock id for matching `container_key` props (`opens_lock_id`).
1290    #[serde(default)]
1291    pub lock_id: Option<String>,
1292    /// Internal storage capacity (liters) from item catalog.
1293    #[serde(default)]
1294    pub capacity_volume: Option<f32>,
1295    /// Container instance id (for MoveItem parent targeting).
1296    #[serde(default)]
1297    pub item_instance_id: Option<Uuid>,
1298    /// Optional gfx tile id from the item template.
1299    #[serde(default)]
1300    pub tile_id: Option<String>,
1301}
1302
1303#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1304pub struct BlueprintIngredientView {
1305    pub template_id: String,
1306    pub quantity: u32,
1307    /// Always serialize — `true` is not bool::default() so postcard keeps it; explicit for clarity.
1308    pub consumed: bool,
1309}
1310
1311#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1312pub struct ToolRequirementView {
1313    pub item: String,
1314    /// Always serialize — postcard omits `false` by default, which breaks roundtrip without explicit value.
1315    pub consumed: bool,
1316}
1317
1318#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1319pub struct SkillRequirementView {
1320    pub skill: String,
1321    pub level: u32,
1322}
1323
1324#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1325pub struct BlueprintView {
1326    pub id: String,
1327    pub label: String,
1328    pub output: String,
1329    pub output_qty: u32,
1330    pub craft_ticks: u32,
1331    pub inputs: Vec<BlueprintIngredientView>,
1332    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
1333    #[serde(default)]
1334    pub station: Option<String>,
1335    #[serde(default)]
1336    pub category: Option<String>,
1337    #[serde(default)]
1338    pub required_tools: Vec<ToolRequirementView>,
1339    #[serde(default)]
1340    pub skill: Option<SkillRequirementView>,
1341    #[serde(default)]
1342    pub failure_chance: f32,
1343}
1344
1345/// Terrain overlay from segment YAML (`terrain_zones`).
1346#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1347#[serde(rename_all = "snake_case")]
1348pub enum TerrainKindView {
1349    #[default]
1350    Grass,
1351    Hill,
1352    Bog,
1353    ShallowWater,
1354    DeepWater,
1355    Trail,
1356    Rock,
1357}
1358
1359#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1360pub struct TerrainZoneView {
1361    pub id: String,
1362    pub x0: f32,
1363    pub y0: f32,
1364    pub x1: f32,
1365    pub y1: f32,
1366    #[serde(default)]
1367    pub kind: TerrainKindView,
1368    /// Ground elevation at this zone (m).
1369    #[serde(default)]
1370    pub elevation: f32,
1371    /// Optional map glyph override (single character); falls back to terrain kind catalog.
1372    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
1373    #[serde(default)]
1374    pub glyph: Option<String>,
1375    /// Optional color (`#RRGGBB` or ratatui name); falls back to kind / elevation tint.
1376    #[serde(default)]
1377    pub color: Option<String>,
1378    /// Optional gfx tile id (`terrain.grass`, …) — presentation only.
1379    #[serde(default)]
1380    pub tile_id: Option<String>,
1381}
1382
1383/// Walkable platform at a fixed z (`segment.z_platforms`).
1384#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1385pub struct ZPlatformView {
1386    pub id: String,
1387    pub z: f32,
1388    pub x0: f32,
1389    pub y0: f32,
1390    pub x1: f32,
1391    pub y1: f32,
1392}
1393
1394/// Stairs / ramp linking two z bands (`segment.z_transitions`).
1395#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1396pub struct ZTransitionView {
1397    pub id: String,
1398    pub z_from: f32,
1399    pub z_to: f32,
1400    pub x0: f32,
1401    pub y0: f32,
1402    pub x1: f32,
1403    pub y1: f32,
1404}
1405
1406#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1407pub struct BuildingView {
1408    pub id: String,
1409    pub label: String,
1410    pub x: f32,
1411    pub y: f32,
1412    pub width_m: f32,
1413    pub depth_m: f32,
1414    #[serde(default)]
1415    pub interior_blueprint: Option<String>,
1416    #[serde(default)]
1417    pub tags: Vec<String>,
1418}
1419
1420#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1421pub struct DoorView {
1422    pub id: String,
1423    pub building_id: String,
1424    pub x: f32,
1425    pub y: f32,
1426    #[serde(default)]
1427    pub open: bool,
1428    #[serde(default)]
1429    pub portal: Option<String>,
1430}
1431
1432#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1433pub struct InteriorRoomView {
1434    pub id: String,
1435    pub label: String,
1436    pub floor: i32,
1437    pub x0: f32,
1438    pub y0: f32,
1439    pub x1: f32,
1440    pub y1: f32,
1441    #[serde(default)]
1442    pub floor_color: Option<String>,
1443    #[serde(default)]
1444    pub floor_glyph: Option<String>,
1445}
1446
1447#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1448pub struct InteriorDoorView {
1449    pub id: String,
1450    pub room_a: String,
1451    pub room_b: String,
1452    pub x: f32,
1453    pub y: f32,
1454    pub kind: String,
1455}
1456
1457#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1458pub struct InteriorMapView {
1459    pub building_id: String,
1460    pub blueprint_id: String,
1461    pub background_color: String,
1462    #[serde(default)]
1463    pub default_floor_color: Option<String>,
1464    #[serde(default = "default_floor_height_view")]
1465    pub floor_height_m: f32,
1466    pub rooms: Vec<InteriorRoomView>,
1467    #[serde(default)]
1468    pub room_doors: Vec<InteriorDoorView>,
1469}
1470
1471fn default_floor_height_view() -> f32 {
1472    3.0
1473}
1474
1475#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1476pub struct NpcView {
1477    pub id: String,
1478    pub label: String,
1479    pub role: String,
1480    pub x: f32,
1481    pub y: f32,
1482    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
1483    #[serde(default)]
1484    pub building_id: Option<String>,
1485    /// Authoritative sim entity for wildlife / combat targets.
1486    #[serde(default)]
1487    pub entity_id: Option<EntityId>,
1488    #[serde(default)]
1489    pub life_state: Option<LifeState>,
1490    #[serde(default)]
1491    pub hp_pct: Option<f32>,
1492    /// True when this NPC has buy/sell/teach offers (`npc_has_market`).
1493    #[serde(default)]
1494    pub can_trade: bool,
1495    /// Gfx sprite sheet id (`assets/gfx/sprites/`). Client falls back to `npc.{id}` / `npc.{role}`.
1496    #[serde(default)]
1497    pub tile_id: Option<String>,
1498    /// Wildlife FSM state (`idle`, `chase`, `combat`, …) when behavior-driven (debug).
1499    #[serde(default)]
1500    pub behavior_state: Option<String>,
1501    /// Canonical gfx presentation key (`combat`, `pursue`, `walking`, `talking`, …).
1502    #[serde(default)]
1503    pub presentation_state: Option<String>,
1504    /// Resolved gfx sprite mode for `tile_id` (server-computed).
1505    #[serde(default)]
1506    pub sprite_mode: Option<String>,
1507}
1508
1509#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1510pub struct UseResult {
1511    pub template_id: String,
1512    pub hunger_restored: f32,
1513    pub thirst_restored: f32,
1514    #[serde(default)]
1515    pub health_restored: f32,
1516    #[serde(default)]
1517    pub mana_restored: f32,
1518    #[serde(default)]
1519    pub cleared_dot_ids: Vec<String>,
1520}
1521
1522#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1523pub struct CraftResult {
1524    pub blueprint_id: String,
1525    pub outputs: Vec<ItemStack>,
1526    pub consumed: Vec<ItemStack>,
1527    /// 1-based index within the submitted batch (1 when not batching).
1528    #[serde(default = "default_one")]
1529    pub batch_index: u32,
1530    /// Total crafts requested in this batch (1 when not batching).
1531    #[serde(default = "default_one")]
1532    pub batch_total: u32,
1533}
1534
1535fn default_one() -> u32 {
1536    1
1537}
1538
1539#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1540pub struct DeathNotice {
1541    pub entity_id: EntityId,
1542    pub respawn_x: f32,
1543    pub respawn_y: f32,
1544    pub message: String,
1545}
1546
1547#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1548pub struct InteractionNotice {
1549    pub target_id: String,
1550    pub message: String,
1551    #[serde(default)]
1552    pub coins_delta: i32,
1553    #[serde(default)]
1554    pub inventory_delta: Vec<ItemStack>,
1555}
1556
1557#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1558#[serde(rename_all = "snake_case")]
1559pub enum NpcTalkTrustFlag {
1560    Stranger,
1561    Acquainted,
1562    Trusted,
1563}
1564
1565#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1566pub struct NpcTalkOpened {
1567    pub npc_id: String,
1568    pub npc_label: String,
1569    pub greeting: String,
1570    pub trust_flag: NpcTalkTrustFlag,
1571}
1572
1573#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1574pub struct NpcTalkPending {
1575    pub npc_id: String,
1576}
1577
1578#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1579pub struct NpcTalkReply {
1580    pub npc_id: String,
1581    pub line: String,
1582    pub trust_flag: NpcTalkTrustFlag,
1583}
1584
1585#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1586pub struct NpcTalkClosed {
1587    pub npc_id: String,
1588}
1589
1590#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1591pub struct NpcTalkError {
1592    pub npc_id: String,
1593    pub reason: String,
1594}
1595
1596#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1597#[serde(rename_all = "snake_case")]
1598pub enum QuestStatusView {
1599    Available,
1600    Active,
1601    Completed,
1602}
1603
1604#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1605pub struct QuestObjectiveProgress {
1606    pub label: String,
1607    pub current: u32,
1608    pub required: u32,
1609    pub done: bool,
1610}
1611
1612#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1613pub struct QuestLogEntry {
1614    pub quest_id: String,
1615    pub title: String,
1616    pub description: String,
1617    pub status: QuestStatusView,
1618    #[serde(default)]
1619    pub current_step_id: Option<String>,
1620    #[serde(default)]
1621    pub current_step_title: String,
1622    #[serde(default)]
1623    pub objectives: Vec<QuestObjectiveProgress>,
1624    #[serde(default)]
1625    pub is_tracked: bool,
1626    #[serde(default)]
1627    pub can_withdraw: bool,
1628}
1629
1630#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1631pub struct InteractableView {
1632    pub id: String,
1633    pub kind: String,
1634    pub label: String,
1635    pub x: f32,
1636    pub y: f32,
1637    pub z: f32,
1638    #[serde(default)]
1639    pub board_id: Option<String>,
1640}
1641
1642#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1643pub struct QuestOffer {
1644    pub quest_id: String,
1645    pub title: String,
1646    pub description: String,
1647    #[serde(default)]
1648    pub step_count: u32,
1649}
1650
1651#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1652pub struct QuestNotice {
1653    pub quest_id: String,
1654    pub title: String,
1655    pub message: String,
1656}
1657
1658#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1659#[serde(rename_all = "snake_case")]
1660pub enum ShopOfferKind {
1661    Item,
1662    Blueprint,
1663}
1664
1665#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1666pub struct ShopOffer {
1667    pub offer_id: String,
1668    pub kind: ShopOfferKind,
1669    pub label: String,
1670    #[serde(default)]
1671    pub template_id: Option<String>,
1672    #[serde(default)]
1673    pub blueprint_id: Option<String>,
1674    pub price_copper: u32,
1675    #[serde(default)]
1676    pub affordable: bool,
1677    #[serde(default)]
1678    pub already_owned: bool,
1679}
1680
1681#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1682pub struct ShopBuyLine {
1683    pub template_id: String,
1684    pub label: String,
1685    pub quantity: u32,
1686    pub price_copper: u32,
1687}
1688
1689#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1690pub struct ShopCatalog {
1691    pub npc_id: String,
1692    pub npc_label: String,
1693    #[serde(default)]
1694    pub sells: Vec<ShopOffer>,
1695    #[serde(default)]
1696    pub buys: Vec<ShopBuyLine>,
1697}
1698
1699#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1700pub struct HarvestResult {
1701    pub node_id: String,
1702    /// Stack quantity granted (not one-node-one-instance).
1703    pub quantity: u32,
1704    pub item_template: String,
1705    /// Optional DB row id when persisted to control plane.
1706    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
1707    #[serde(default)]
1708    pub item_instance_id: Option<Uuid>,
1709}
1710
1711/// Every on-wire payload is wrapped for versioning and codec uniformity.
1712#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1713pub struct Envelope<T> {
1714    pub protocol_version: u16,
1715    pub payload: T,
1716}
1717
1718impl<T> Envelope<T> {
1719    pub fn new(payload: T) -> Self {
1720        Self {
1721            protocol_version: crate::PROTOCOL_VERSION,
1722            payload,
1723        }
1724    }
1725}
1726
1727/// Session handshake after transport connect.
1728#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1729pub struct Hello {
1730    pub client_name: String,
1731    pub protocol_version: u16,
1732    #[serde(default)]
1733    pub auth: AuthCredential,
1734    /// Required for session auth; embedded in `ApiToken` variant otherwise.
1735    #[serde(default)]
1736    pub character_id: Option<Uuid>,
1737}
1738
1739/// How the client authenticates to the game gateway.
1740/// Uses default serde enum encoding (postcard-compatible; not internally tagged).
1741#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1742#[serde(rename_all = "snake_case")]
1743pub enum AuthCredential {
1744    DevLocal,
1745    Session { token: String },
1746    ApiToken { token: String, character_id: Uuid },
1747}
1748
1749impl Default for AuthCredential {
1750    fn default() -> Self {
1751        Self::DevLocal
1752    }
1753}
1754
1755#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1756pub struct Welcome {
1757    pub session_id: SessionId,
1758    pub entity_id: EntityId,
1759    pub snapshot: Snapshot,
1760}
1761
1762#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1763pub enum ServerMessage {
1764    Welcome(Welcome),
1765    /// Static world layers refreshed (blueprints, catalog, map, terrain) — no client restart.
1766    ContentUpdated(Snapshot),
1767    Tick(TickDelta),
1768    IntentAck {
1769        entity_id: EntityId,
1770        seq: Seq,
1771        tick: Tick,
1772    },
1773    Chat(ChatMessage),
1774    HarvestResult(HarvestResult),
1775    UseResult(UseResult),
1776    CraftResult(CraftResult),
1777    Death(DeathNotice),
1778    Interaction(InteractionNotice),
1779    ShopOpened(ShopCatalog),
1780    NpcTalkOpened(NpcTalkOpened),
1781    NpcTalkPending(NpcTalkPending),
1782    NpcTalkReply(NpcTalkReply),
1783    NpcTalkClosed(NpcTalkClosed),
1784    NpcTalkError(NpcTalkError),
1785    QuestOffer(QuestOffer),
1786    QuestAccepted(QuestNotice),
1787    QuestWithdrawn(QuestNotice),
1788    QuestStepCompleted(QuestNotice),
1789    QuestCompleted(QuestNotice),
1790}
1791
1792#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1793pub enum ClientMessage {
1794    Hello(Hello),
1795    Intent(Intent),
1796    Disconnect,
1797}
1798
1799#[cfg(test)]
1800mod tests {
1801    use super::*;
1802
1803    #[test]
1804    fn pristine_vitals_state_yields_full_pools() {
1805        let attrs = PrimaryAttributes::default();
1806        let vitals = StoredVitalsState::default().apply_to(attrs);
1807        assert!(vitals.health > 0.0);
1808        assert_eq!(vitals.health, vitals.health_max);
1809        assert!((vitals.mana_max - 61.0).abs() < 0.01);
1810    }
1811
1812    #[test]
1813    fn saved_vitals_scale_when_pool_max_increases() {
1814        let mut attrs = PrimaryAttributes::default();
1815        attrs.intelligence = 140;
1816        attrs.wisdom = 140;
1817        let saved = StoredVitalsState {
1818            health: 100.0,
1819            mana: 14.0,
1820            stamina: 100.0,
1821            ..StoredVitalsState::default()
1822        };
1823        let vitals = saved.apply_to(attrs);
1824        assert!(vitals.mana_max > 55.0);
1825        assert!(
1826            (vitals.mana - vitals.mana_max).abs() < 0.01,
1827            "full legacy mana bar migrates to full new bar"
1828        );
1829    }
1830
1831    #[test]
1832    fn empty_vitals_state_is_pristine() {
1833        let pristine = StoredVitalsState {
1834            health: 0.0,
1835            mana: 0.0,
1836            stamina: 0.0,
1837            hunger: 0.0,
1838            thirst: 0.0,
1839            coins: 0,
1840            deaths: 0,
1841            life_state: LifeState::Alive,
1842        };
1843        assert!(pristine.is_pristine());
1844        let vitals = pristine.apply_to(PrimaryAttributes::default());
1845        assert!(vitals.health > 0.0);
1846    }
1847
1848    #[test]
1849    fn stored_vitals_roundtrip_preserves_partial_pools() {
1850        let attrs = PrimaryAttributes::default();
1851        let mut live = PlayerVitals::from_attributes(attrs);
1852        live.health = 25.0;
1853        live.hunger = 77.0;
1854        live.deaths = 2;
1855        let stored = StoredVitalsState::from_live(&live);
1856        let restored = stored.apply_to(attrs);
1857        assert!(
1858            (restored.health - 25.0).abs() < 0.01,
1859            "partial HP below cap stays absolute"
1860        );
1861        assert_eq!(restored.hunger, 77.0);
1862        assert_eq!(restored.deaths, 2);
1863    }
1864
1865    #[test]
1866    fn skill_tiers_start_at_zero() {
1867        let skill = SkillProgress::default();
1868        assert_eq!(skill.level, 0);
1869        assert_eq!(skill.display_tier(), 0);
1870        let trained = SkillProgress {
1871            level: 250,
1872            last_trained_tick: 1,
1873        };
1874        assert_eq!(trained.display_tier(), 2);
1875    }
1876
1877    #[test]
1878    fn quest_server_messages_roundtrip_json() {
1879        use crate::codec::{Codec, PostcardCodec};
1880
1881        let offer = ServerMessage::QuestOffer(QuestOffer {
1882            quest_id: "ada_goblin_hunt".into(),
1883            title: "Goblin Trouble".into(),
1884            description: "Help Ada".into(),
1885            step_count: 3,
1886        });
1887        let notice = ServerMessage::QuestAccepted(QuestNotice {
1888            quest_id: "ada_goblin_hunt".into(),
1889            title: "Goblin Trouble".into(),
1890            message: "Quest accepted".into(),
1891        });
1892        for msg in [offer, notice] {
1893            let bytes = PostcardCodec.encode(&msg).unwrap();
1894            let decoded: ServerMessage = PostcardCodec.decode(&bytes).unwrap();
1895            assert_eq!(decoded, msg);
1896        }
1897    }
1898}