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