Skip to main content

flatland_protocol/
types.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6pub type EntityId = u64;
7pub type Tick = u64;
8pub type Seq = u32;
9pub type SessionId = u64;
10
11/// World position `(x, y, z, w, t)` — `t` reserved at 0.
12#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
13pub struct WorldCoord {
14    pub x: f32,
15    pub y: f32,
16    pub z: f32,
17    pub w: u32,
18    pub t: u32,
19}
20
21impl WorldCoord {
22    pub fn surface(x: f32, y: f32) -> Self {
23        Self {
24            x,
25            y,
26            z: 0.0,
27            w: 0,
28            t: 0,
29        }
30    }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
34pub struct Velocity2D {
35    pub vx: f32,
36    pub vy: f32,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
40pub struct Transform {
41    pub position: WorldCoord,
42    pub yaw: f32,
43    pub velocity: Velocity2D,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum LifeState {
49    Alive,
50    Dead,
51}
52
53impl Default for LifeState {
54    fn default() -> Self {
55        Self::Alive
56    }
57}
58
59/// Named slice of the in-game day (drives future weather / spawn tables).
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum TimeOfDayPhase {
63    Night,
64    Dawn,
65    Morning,
66    Midday,
67    Afternoon,
68    Evening,
69}
70
71impl TimeOfDayPhase {
72    pub fn label(self) -> &'static str {
73        match self {
74            Self::Night => "Night",
75            Self::Dawn => "Dawn",
76            Self::Morning => "Morning",
77            Self::Midday => "Midday",
78            Self::Afternoon => "Afternoon",
79            Self::Evening => "Evening",
80        }
81    }
82
83    pub fn from_name(name: &str) -> Self {
84        match name.to_ascii_lowercase().as_str() {
85            "dawn" => Self::Dawn,
86            "morning" => Self::Morning,
87            "midday" | "mid_day" | "noon" => Self::Midday,
88            "afternoon" => Self::Afternoon,
89            "evening" | "dusk" => Self::Evening,
90            _ => Self::Night,
91        }
92    }
93}
94
95/// Authoritative region clock (one in-game day = configurable real duration).
96#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
97pub struct WorldClock {
98    /// Days elapsed since region start (0-based).
99    pub day: u64,
100    pub hour: u8,
101    pub minute: u8,
102    pub phase: TimeOfDayPhase,
103}
104
105impl Default for WorldClock {
106    fn default() -> Self {
107        Self {
108            day: 0,
109            hour: 8,
110            minute: 0,
111            phase: TimeOfDayPhase::Morning,
112        }
113    }
114}
115
116impl WorldClock {
117    pub fn display_time(self) -> String {
118        format!("{:02}:{:02}", self.hour, self.minute)
119    }
120}
121
122/// Core primary stats — internal scale 1–1000; UI displays `value / 10` (1–100).
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124pub struct PrimaryAttributes {
125    pub strength: u16,
126    pub dexterity: u16,
127    pub intelligence: u16,
128    pub stamina: u16,
129    pub vitality: u16,
130    pub wisdom: u16,
131    pub charisma: u16,
132}
133
134impl Default for PrimaryAttributes {
135    fn default() -> Self {
136        Self {
137            strength: 150,
138            dexterity: 150,
139            intelligence: 150,
140            stamina: 150,
141            vitality: 150,
142            wisdom: 150,
143            charisma: 150,
144        }
145    }
146}
147
148impl PrimaryAttributes {
149    /// Map internal 1–1000 to player-facing 1–100.
150    pub fn display(value: u16) -> u16 {
151        (value / 10).clamp(1, 100)
152    }
153
154    /// Client-facing derived stat preview (mirrors sim tuning).
155    pub fn derived_preview(&self) -> DerivedPreview {
156        let str_d = Self::display(self.strength) as f32;
157        let dex_d = Self::display(self.dexterity) as f32;
158        let int_d = Self::display(self.intelligence) as f32;
159        let wis_d = Self::display(self.wisdom) as f32;
160        DerivedPreview {
161            attack_power: str_d * 1.2 + dex_d * 0.3,
162            spell_power: int_d * 1.1 + wis_d * 0.4,
163            evasion: dex_d * 0.8 + wis_d * 0.2,
164            carry_mass_max: str_d * 2.5,
165            sight_range_m: 12.0 + wis_d * 0.15 + dex_d * 0.05,
166            fov_deg: 120.0 + wis_d * 0.2,
167        }
168    }
169}
170
171/// Derived combat/survival preview for character sheet UI.
172#[derive(Debug, Clone, Copy, PartialEq)]
173pub struct DerivedPreview {
174    pub attack_power: f32,
175    pub spell_power: f32,
176    pub evasion: f32,
177    pub carry_mass_max: f32,
178    pub sight_range_m: f32,
179    pub fov_deg: f32,
180}
181
182/// Trained skill — internal 0–1000; UI uses tiers / display level.
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
184pub struct SkillProgress {
185    pub level: u16,
186    #[serde(default)]
187    pub last_trained_tick: u64,
188}
189
190impl Default for SkillProgress {
191    fn default() -> Self {
192        Self {
193            level: 0,
194            last_trained_tick: 0,
195        }
196    }
197}
198
199impl SkillProgress {
200    /// Mastery tier 0–10 (`plans/11` §10). Tier 0 = untrained; tier 1 begins at internal 100.
201    pub fn display_tier(&self) -> u16 {
202        (self.level / 100).min(10)
203    }
204}
205
206/// Fractional XP pools — source of truth for progression curve (`plans/27`).
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
208#[serde(default)]
209pub struct ProgressionXp {
210    pub strength: f64,
211    pub dexterity: f64,
212    pub intelligence: f64,
213    pub stamina: f64,
214    pub vitality: f64,
215    pub wisdom: f64,
216    pub charisma: f64,
217    pub logging: f64,
218    pub mining: f64,
219    pub evocation: f64,
220    pub restoration: f64,
221    pub swords: f64,
222    pub archery: f64,
223    pub crafting: f64,
224    pub alchemy: f64,
225    pub cartography: f64,
226}
227
228impl ProgressionXp {
229    /// Bootstrap pools for a new character at display baseline 15.
230    pub fn bootstrap_new(baseline_display: u16, xp_base: f64, xp_growth: f64) -> Self {
231        let bootstrap = |display: f64| {
232            if display <= 1.0 {
233                0.0
234            } else {
235                xp_base * xp_growth.powf(display - 1.0)
236            }
237        };
238        let b = baseline_display as f64;
239        let primary = bootstrap(b);
240        Self {
241            strength: primary,
242            dexterity: primary,
243            intelligence: primary,
244            stamina: primary,
245            vitality: primary,
246            wisdom: primary,
247            charisma: primary,
248            ..Self::default()
249        }
250    }
251
252    pub fn is_empty(&self) -> bool {
253        self.strength == 0.0
254            && self.dexterity == 0.0
255            && self.intelligence == 0.0
256            && self.stamina == 0.0
257            && self.vitality == 0.0
258            && self.wisdom == 0.0
259            && self.charisma == 0.0
260            && self.logging == 0.0
261            && self.mining == 0.0
262            && self.evocation == 0.0
263            && self.restoration == 0.0
264            && self.swords == 0.0
265            && self.archery == 0.0
266            && self.crafting == 0.0
267            && self.alchemy == 0.0
268            && self.cartography == 0.0
269    }
270}
271
272/// Core trained skills shipped at launch (`plans/11` §10).
273#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
274#[serde(default)]
275pub struct PlayerSkills {
276    pub logging: SkillProgress,
277    pub mining: SkillProgress,
278    pub evocation: SkillProgress,
279    #[serde(default)]
280    pub restoration: SkillProgress,
281    pub swords: SkillProgress,
282    #[serde(default)]
283    pub archery: SkillProgress,
284    pub crafting: SkillProgress,
285    #[serde(default)]
286    pub alchemy: SkillProgress,
287    pub cartography: SkillProgress,
288}
289
290impl Default for PlayerSkills {
291    fn default() -> Self {
292        Self {
293            logging: SkillProgress::default(),
294            mining: SkillProgress::default(),
295            evocation: SkillProgress::default(),
296            restoration: SkillProgress::default(),
297            swords: SkillProgress::default(),
298            archery: SkillProgress::default(),
299            crafting: SkillProgress::default(),
300            alchemy: SkillProgress::default(),
301            cartography: SkillProgress::default(),
302        }
303    }
304}
305
306/// Player health / resources (players only; omitted on other entities).
307#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
308pub struct PlayerVitals {
309    pub health: f32,
310    pub health_max: f32,
311    pub mana: f32,
312    pub mana_max: f32,
313    pub stamina: f32,
314    pub stamina_max: f32,
315    #[serde(default = "default_survival_pool_max")]
316    pub hunger: f32,
317    #[serde(default = "default_survival_pool_max")]
318    pub hunger_max: f32,
319    #[serde(default = "default_survival_pool_max")]
320    pub thirst: f32,
321    #[serde(default = "default_survival_pool_max")]
322    pub thirst_max: f32,
323    #[serde(default)]
324    pub coins: u32,
325    #[serde(default)]
326    pub deaths: u32,
327    #[serde(default)]
328    pub life_state: LifeState,
329}
330
331fn default_survival_pool_max() -> f32 {
332    100.0
333}
334
335impl Default for PlayerVitals {
336    fn default() -> Self {
337        Self::from_attributes(PrimaryAttributes::default())
338    }
339}
340
341impl PlayerVitals {
342    /// Build pool maximums from primary attributes (current pools filled to max).
343    ///
344    /// Uses **display** stats (internal ÷ 10) so pools scale intuitively with
345    /// progression, gear, and future enchant modifiers on attributes.
346    ///
347    /// Default attrs (all 150 → display 15): ~80 HP, ~51 stamina, ~61 mana.
348    pub fn from_attributes(attrs: PrimaryAttributes) -> Self {
349        let vit_d = PrimaryAttributes::display(attrs.vitality) as f32;
350        let int_d = PrimaryAttributes::display(attrs.intelligence) as f32;
351        let wis_d = PrimaryAttributes::display(attrs.wisdom) as f32;
352        let sta_d = PrimaryAttributes::display(attrs.stamina) as f32;
353
354        let health_max = 50.0 + vit_d * 2.0;
355        let stamina_max = 30.0 + sta_d * 1.4;
356        let mana_max = 25.0 + int_d * 1.1 + wis_d * 1.3;
357        let hunger_max = 100.0;
358        let thirst_max = 100.0;
359        Self {
360            health: health_max,
361            health_max,
362            mana: mana_max,
363            mana_max,
364            stamina: stamina_max,
365            stamina_max,
366            hunger: hunger_max,
367            hunger_max,
368            thirst: thirst_max,
369            thirst_max,
370            coins: 0,
371            deaths: 0,
372            life_state: LifeState::Alive,
373        }
374    }
375
376    /// Legacy pool maxima (pre-2026-07 pool rebalance) for migration scaling.
377    pub fn legacy_maxima(attrs: PrimaryAttributes) -> (f32, f32, f32) {
378        (
379            attrs.vitality as f32 / 5.0,
380            attrs.stamina as f32 / 5.0,
381            (attrs.intelligence as f32 + attrs.wisdom as f32) / 20.0,
382        )
383    }
384}
385
386/// Durable pool snapshot for Postgres (max values recomputed from attributes on load).
387#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
388#[serde(default)]
389pub struct StoredVitalsState {
390    pub health: f32,
391    pub mana: f32,
392    pub stamina: f32,
393    pub hunger: f32,
394    pub thirst: f32,
395    pub coins: u32,
396    pub deaths: u32,
397    pub life_state: LifeState,
398}
399
400impl StoredVitalsState {
401    pub fn from_live(v: &PlayerVitals) -> Self {
402        Self {
403            health: v.health,
404            mana: v.mana,
405            stamina: v.stamina,
406            hunger: v.hunger,
407            thirst: v.thirst,
408            coins: v.coins,
409            deaths: v.deaths,
410            life_state: v.life_state,
411        }
412    }
413
414    /// Empty `{}` JSON from a new character row — not a real gameplay snapshot.
415    pub fn is_pristine(&self) -> bool {
416        self.health == 0.0
417            && self.mana == 0.0
418            && self.stamina == 0.0
419            && self.hunger == 0.0
420            && self.thirst == 0.0
421            && self.coins == 0
422            && self.deaths == 0
423            && self.life_state == LifeState::Alive
424    }
425
426    pub fn apply_to(self, attrs: PrimaryAttributes) -> PlayerVitals {
427        if self.is_pristine() {
428            return PlayerVitals::from_attributes(attrs);
429        }
430        let fresh = PlayerVitals::from_attributes(attrs);
431        let (legacy_hp, legacy_sta, legacy_mana) = PlayerVitals::legacy_maxima(attrs);
432
433        let scale = |current: f32, legacy_max: f32, new_max: f32| {
434            if legacy_max > 0.0 && new_max > legacy_max * 1.05 && current >= legacy_max * 0.95 {
435                let ratio = (current / legacy_max).clamp(0.0, 1.0);
436                (new_max * ratio).min(new_max)
437            } else {
438                current.min(new_max)
439            }
440        };
441
442        let mut v = fresh;
443        v.health = scale(self.health, legacy_hp, fresh.health_max);
444        v.mana = scale(self.mana, legacy_mana, fresh.mana_max);
445        v.stamina = scale(self.stamina, legacy_sta, fresh.stamina_max);
446        v.hunger = self.hunger.min(v.hunger_max);
447        v.thirst = self.thirst.min(v.thirst_max);
448        v.coins = self.coins;
449        v.deaths = self.deaths;
450        v.life_state = self.life_state;
451        v
452    }
453}
454
455impl Default for StoredVitalsState {
456    fn default() -> Self {
457        Self::from_live(&PlayerVitals::default())
458    }
459}
460
461/// Saved ability rotation preset (`plans/26` §C3).
462#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
463pub struct RotationPreset {
464    pub id: String,
465    pub label: String,
466    #[serde(default)]
467    pub abilities: Vec<String>,
468}
469
470impl RotationPreset {
471    pub fn melee_default(ability_id: impl Into<String>) -> Self {
472        let id = ability_id.into();
473        Self {
474            id: "melee".into(),
475            label: "Melee".into(),
476            abilities: vec![id],
477        }
478    }
479}
480
481/// Durable combat state (`plans/12` §4.1) — target by wildlife instance id, not entity id.
482#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
483pub struct StoredTargetSlot {
484    pub instance_id: Option<String>,
485    #[serde(default)]
486    pub preset_id: Option<String>,
487    #[serde(default)]
488    pub rotation_index: u32,
489    #[serde(default)]
490    pub auto_enabled: bool,
491}
492
493#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
494#[serde(default)]
495pub struct StoredCombatProfile {
496    /// Wildlife `instance_id` (e.g. `meadow-rabbits-0`), stable across worker restarts.
497    pub combat_target_instance_id: Option<String>,
498    pub in_combat: bool,
499    pub last_combat_tick: u64,
500    pub last_attack_tick: u64,
501    pub cooldowns_until_tick: BTreeMap<String, u64>,
502    #[serde(default = "default_auto_attack")]
503    pub auto_attack_enabled: bool,
504    /// Equipped mainhand weapon template id (`plans/26` §C2b).
505    #[serde(default)]
506    pub mainhand_template_id: Option<String>,
507    /// Equipped body-slot items (backpack, belt, future armor), nested contents included
508    /// (e.g. pouches clipped onto a worn belt). At most one entry per `BodySlot`.
509    #[serde(default)]
510    pub worn: Vec<(BodySlot, ItemStack)>,
511    /// Player-authored rotation library (`plans/26` §C3).
512    #[serde(default)]
513    pub rotation_presets: Vec<RotationPreset>,
514    /// Per-target slot state (T1/T2).
515    #[serde(default)]
516    pub target_slots: Vec<StoredTargetSlot>,
517    /// Discovered craft recipe ids (`plans/09` §C2 hybrid discovery).
518    #[serde(default)]
519    pub known_blueprint_ids: Vec<String>,
520    /// Keys stowed on the keychain (not counted toward carry mass).
521    #[serde(default)]
522    pub keychain: Vec<ItemStack>,
523}
524
525fn default_auto_attack() -> bool {
526    true
527}
528
529impl Default for StoredCombatProfile {
530    fn default() -> Self {
531        Self {
532            combat_target_instance_id: None,
533            in_combat: false,
534            last_combat_tick: 0,
535            last_attack_tick: 0,
536            cooldowns_until_tick: BTreeMap::new(),
537            auto_attack_enabled: true,
538            mainhand_template_id: None,
539            worn: Vec::new(),
540            rotation_presets: Vec::new(),
541            target_slots: Vec::new(),
542            known_blueprint_ids: Vec::new(),
543            keychain: Vec::new(),
544        }
545    }
546}
547
548#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
549pub struct EntityState {
550    pub id: EntityId,
551    pub transform: Transform,
552    /// Character / display name (first letter used as map glyph for other players).
553    #[serde(default)]
554    pub label: String,
555    #[serde(default)]
556    pub vitals: Option<PlayerVitals>,
557    /// Primary attributes (local player / inspect).
558    #[serde(default)]
559    pub attributes: Option<PrimaryAttributes>,
560    #[serde(default)]
561    pub skills: Option<PlayerSkills>,
562    /// When inside a building interior (players only).
563    #[serde(default)]
564    pub inside_building: Option<String>,
565    /// Gfx sprite sheet id for player avatars (`player.hero`, …).
566    #[serde(default)]
567    pub tile_id: Option<String>,
568    /// Canonical gfx presentation key (`walking`, `combat`, `harvesting`, …).
569    #[serde(default)]
570    pub presentation_state: Option<String>,
571    /// Resolved gfx sprite mode for `tile_id` (server-computed).
572    #[serde(default)]
573    pub sprite_mode: Option<String>,
574    /// XP pools for local player progression display (omitted on NPCs / other players).
575    #[serde(default)]
576    pub progression_xp: Option<ProgressionXp>,
577}
578
579/// Nearby voice chat vs magical long-range (requires whisper-stone item).
580#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
581#[serde(rename_all = "snake_case")]
582pub enum ChatChannel {
583    Nearby,
584    WhisperStone,
585}
586
587#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
588pub struct ChatMessage {
589    pub channel: ChatChannel,
590    pub from_entity: EntityId,
591    pub from_name: String,
592    pub text: String,
593    pub tick: Tick,
594}
595
596/// Client → server gameplay input (reliable, sequenced per entity).
597#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
598pub enum Intent {
599    Move {
600        entity_id: EntityId,
601        forward: f32,
602        strafe: f32,
603        /// Climb (+) or descend (−) on z axis (m/s intent).
604        #[serde(default)]
605        vertical: f32,
606        /// Sprint multiplier when stamina allows.
607        #[serde(default)]
608        sprint: bool,
609        seq: Seq,
610    },
611    Stop {
612        entity_id: EntityId,
613        seq: Seq,
614    },
615    Harvest {
616        entity_id: EntityId,
617        node_id: String,
618        seq: Seq,
619    },
620    Use {
621        entity_id: EntityId,
622        template_id: String,
623        seq: Seq,
624    },
625    Say {
626        entity_id: EntityId,
627        channel: ChatChannel,
628        text: String,
629        seq: Seq,
630    },
631    /// Start a blueprint craft (timed, like harvest).
632    Craft {
633        entity_id: EntityId,
634        blueprint_id: String,
635        /// Batches to run back-to-back; `None` crafts as many as materials/stamina allow.
636        #[serde(default)]
637        count: Option<u32>,
638        seq: Seq,
639    },
640    /// Door, NPC, enter/exit building.
641    Interact {
642        entity_id: EntityId,
643        target_id: String,
644        seq: Seq,
645    },
646    /// Buy from an NPC shop offer (`ShopOpened` catalog).
647    ShopBuy {
648        entity_id: EntityId,
649        npc_id: String,
650        offer_id: String,
651        #[serde(default = "default_one")]
652        quantity: u32,
653        seq: Seq,
654    },
655    /// Sell inventory to an NPC (`ShopOpened` buy list).
656    ShopSell {
657        entity_id: EntityId,
658        npc_id: String,
659        template_id: String,
660        #[serde(default = "default_one")]
661        quantity: u32,
662        seq: Seq,
663    },
664    /// Dev / test: apply damage to self (co-op testing).
665    TestDamage {
666        entity_id: EntityId,
667        amount: f32,
668        seq: Seq,
669    },
670    /// Slot-1 combat target (`plans/12` alias).
671    SetTarget {
672        entity_id: EntityId,
673        target_id: EntityId,
674        seq: Seq,
675    },
676    /// Target slot assignment (`plans/12` §5.3). Slot 1 aliases `SetTarget`.
677    SetTargetSlot {
678        entity_id: EntityId,
679        slot_index: u8,
680        target_id: EntityId,
681        seq: Seq,
682    },
683    ClearTarget {
684        entity_id: EntityId,
685        seq: Seq,
686    },
687    ClearTargetSlot {
688        entity_id: EntityId,
689        slot_index: u8,
690        seq: Seq,
691    },
692    /// Toggle per-slot auto-attack (`plans/12` §4.2).
693    SetAutoAttack {
694        entity_id: EntityId,
695        slot_index: u8,
696        enabled: bool,
697        seq: Seq,
698    },
699    /// Melee attack — uses `target_id` or the player's current target.
700    Attack {
701        entity_id: EntityId,
702        #[serde(default)]
703        target_id: Option<EntityId>,
704        #[serde(default)]
705        weapon_slot: Option<u32>,
706        seq: Seq,
707    },
708    /// Pick up a ground loot pile (nearest in range when `drop_id` omitted).
709    Pickup {
710        entity_id: EntityId,
711        #[serde(default)]
712        drop_id: Option<String>,
713        seq: Seq,
714    },
715    /// Cast a spell or use a non-weapon ability template (`plans/24` §4.3b).
716    Cast {
717        entity_id: EntityId,
718        ability_id: String,
719        target_id: EntityId,
720        seq: Seq,
721    },
722    /// Bind an ability to a target slot action bar (`plans/12` §4.2).
723    BindActionSlot {
724        entity_id: EntityId,
725        slot_index: u8,
726        ability_id: String,
727        #[serde(default = "default_auto_attack")]
728        auto_enabled: bool,
729        seq: Seq,
730    },
731    /// Fire a bound consumable or ability from a slot (`plans/24` §4.3c).
732    UseActionSlot {
733        entity_id: EntityId,
734        slot_index: u8,
735        seq: Seq,
736    },
737    /// Dodge — brief i-frames, stamina cost (`plans/24` §4.3c).
738    Dodge {
739        entity_id: EntityId,
740        seq: Seq,
741    },
742    /// Lunge — burst forward in movement/facing direction, higher stamina cost.
743    Lunge {
744        entity_id: EntityId,
745        /// Last movement forward axis when idle (−1..1).
746        #[serde(default)]
747        forward: f32,
748        /// Last movement strafe axis when idle (−1..1).
749        #[serde(default)]
750        strafe: f32,
751        seq: Seq,
752    },
753    /// Directional jump — leap one cell uphill over a medium cliff (`plans/04` §4.5b).
754    DirectionalJump {
755        entity_id: EntityId,
756        /// Jump direction forward axis (−1..1).
757        #[serde(default)]
758        forward: f32,
759        /// Jump direction strafe axis (−1..1).
760        #[serde(default)]
761        strafe: f32,
762        seq: Seq,
763    },
764    /// Block — frontal mitigation while held (`plans/24` §4.3c).
765    Block {
766        entity_id: EntityId,
767        #[serde(default = "default_block_enabled")]
768        enabled: bool,
769        seq: Seq,
770    },
771    /// Equip or clear mainhand weapon (`plans/26` §C2b). `None` unequips.
772    EquipMainhand {
773        entity_id: EntityId,
774        #[serde(default)]
775        template_id: Option<String>,
776        seq: Seq,
777    },
778    /// Equip a wearable (container, belt, or armor piece) to a body slot, or clear the
779    /// slot when `instance_id` is None.
780    EquipWorn {
781        entity_id: EntityId,
782        slot: BodySlot,
783        #[serde(default)]
784        instance_id: Option<Uuid>,
785        seq: Seq,
786    },
787    /// Move an item instance between root / worn / placed container inventories.
788    MoveItem {
789        entity_id: EntityId,
790        item_instance_id: Uuid,
791        from: InventoryLocation,
792        to: InventoryLocation,
793        /// When moving into a container location, nest under this parent instance (None = container root).
794        #[serde(default)]
795        to_parent_instance_id: Option<Uuid>,
796        /// Units to move; `None` moves the whole stack. Server clamps to volume/carry limits.
797        #[serde(default)]
798        quantity: Option<u32>,
799        seq: Seq,
800    },
801    /// Place a placeable container from inventory onto the ground at the player's feet.
802    PlaceContainer {
803        entity_id: EntityId,
804        item_instance_id: Uuid,
805        seq: Seq,
806    },
807    /// Pick up a placed container (with contents) into inventory.
808    PickupContainer {
809        entity_id: EntityId,
810        container_id: String,
811        seq: Seq,
812    },
813    /// Lock or unlock a worn or placed container (requires matching key when locking/unlocking).
814    SetContainerLocked {
815        entity_id: EntityId,
816        /// Worn slot or placed container id encoded as location.
817        location: InventoryLocation,
818        locked: bool,
819        seq: Seq,
820    },
821    /// Drop a non-container item on the ground at the player's feet.
822    DropItem {
823        entity_id: EntityId,
824        item_instance_id: Uuid,
825        from: InventoryLocation,
826        seq: Seq,
827    },
828    /// Permanently destroy an item stack (not recoverable; no ground drop).
829    DestroyItem {
830        entity_id: EntityId,
831        item_instance_id: Uuid,
832        from: InventoryLocation,
833        /// Units to destroy; `None` destroys the whole stack.
834        #[serde(default)]
835        quantity: Option<u32>,
836        seq: Seq,
837    },
838    /// Set a custom display name on a container instance (chest, pouch, backpack).
839    RenameContainer {
840        entity_id: EntityId,
841        item_instance_id: Uuid,
842        location: InventoryLocation,
843        name: String,
844        seq: Seq,
845    },
846    /// Create or update a rotation preset in the player's library.
847    UpsertRotationPreset {
848        entity_id: EntityId,
849        preset: RotationPreset,
850        seq: Seq,
851    },
852    /// Remove a rotation preset from the library.
853    DeleteRotationPreset {
854        entity_id: EntityId,
855        preset_id: String,
856        seq: Seq,
857    },
858    /// Assign a library preset to target slot T1/T2.
859    AssignSlotPreset {
860        entity_id: EntityId,
861        slot_index: u8,
862        preset_id: String,
863        seq: Seq,
864    },
865    /// Fire the next ready ability in a slot's rotation (manual step).
866    AdvanceRotation {
867        entity_id: EntityId,
868        slot_index: u8,
869        seq: Seq,
870    },
871    /// Open a turn-based conversation with an NPC.
872    NpcTalkOpen {
873        entity_id: EntityId,
874        npc_id: String,
875        seq: Seq,
876    },
877    /// Send a player message in an open NPC conversation.
878    NpcTalkSay {
879        entity_id: EntityId,
880        npc_id: String,
881        message: String,
882        seq: Seq,
883    },
884    /// Close an NPC conversation.
885    NpcTalkClose {
886        entity_id: EntityId,
887        npc_id: String,
888        seq: Seq,
889    },
890    /// Accept a discovered quest (adds to active list).
891    AcceptQuest {
892        entity_id: EntityId,
893        quest_id: String,
894        seq: Seq,
895    },
896    /// Withdraw from an active quest (resets progress; can re-accept).
897    WithdrawQuest {
898        entity_id: EntityId,
899        quest_id: String,
900        seq: Seq,
901    },
902    /// Highlight an active quest in the HUD.
903    TrackQuest {
904        entity_id: EntityId,
905        quest_id: String,
906        seq: Seq,
907    },
908    /// Turn in items for an active give_item objective while near an NPC.
909    QuestGiveItem {
910        entity_id: EntityId,
911        npc_id: String,
912        template_id: String,
913        #[serde(default = "default_one")]
914        quantity: u32,
915        seq: Seq,
916    },
917}
918
919fn default_block_enabled() -> bool {
920    true
921}
922
923/// Observer combat HUD (local player only).
924#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
925pub struct CombatTargetHud {
926    pub entity_id: EntityId,
927    #[serde(default)]
928    pub label: String,
929    #[serde(default)]
930    pub level: u32,
931    pub health: f32,
932    pub health_max: f32,
933    #[serde(default)]
934    pub life_state: LifeState,
935    #[serde(default)]
936    pub distance_m: f32,
937}
938
939/// Active spell cast channel progress.
940#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
941pub struct CastProgressHud {
942    #[serde(default)]
943    pub ability_id: String,
944    #[serde(default)]
945    pub ability_label: String,
946    #[serde(default)]
947    pub ticks_remaining: u64,
948    #[serde(default)]
949    pub ticks_total: u64,
950}
951
952/// Cooldown state for a combat ability shown on the action bar.
953#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
954pub struct AbilityCooldownHud {
955    #[serde(default)]
956    pub ability_id: String,
957    #[serde(default)]
958    pub label: String,
959    #[serde(default)]
960    pub cd_ticks: u64,
961    #[serde(default)]
962    pub cd_total_ticks: u64,
963}
964
965/// One combat target slot in the observer HUD.
966#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
967pub struct CombatSlotHud {
968    pub slot_index: u8,
969    #[serde(default)]
970    pub target_entity_id: Option<EntityId>,
971    #[serde(default)]
972    pub target_label: Option<String>,
973    #[serde(default)]
974    pub target: Option<CombatTargetHud>,
975    #[serde(default)]
976    pub preset_id: Option<String>,
977    #[serde(default)]
978    pub preset_label: Option<String>,
979    #[serde(default)]
980    pub rotation: Vec<String>,
981    #[serde(default)]
982    pub rotation_index: u32,
983    #[serde(default)]
984    pub next_ability_id: Option<String>,
985    #[serde(default)]
986    pub auto_enabled: bool,
987}
988
989/// Observer combat HUD (local player only).
990#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
991pub struct CombatHud {
992    pub in_combat: bool,
993    /// T1 auto rotation (legacy field mirrors slot 1).
994    pub auto_attack: bool,
995    pub has_los: bool,
996    pub attack_cd_ticks: u64,
997    #[serde(default)]
998    pub ability_id: String,
999    #[serde(default)]
1000    pub target_entity_id: Option<EntityId>,
1001    #[serde(default)]
1002    pub target_label: Option<String>,
1003    #[serde(default)]
1004    pub max_target_slots: u8,
1005    #[serde(default)]
1006    pub slots: Vec<CombatSlotHud>,
1007    #[serde(default)]
1008    pub rotation_presets: Vec<RotationPreset>,
1009    #[serde(default)]
1010    pub gcd_ticks: u64,
1011    #[serde(default)]
1012    pub mainhand_template_id: Option<String>,
1013    #[serde(default)]
1014    pub mainhand_label: Option<String>,
1015    /// Equipped body-slot items (backpack, belt w/ clipped pouches, future armor).
1016    #[serde(default)]
1017    pub worn: Vec<(BodySlot, ItemStack)>,
1018    #[serde(default)]
1019    pub carry_mass: f32,
1020    #[serde(default)]
1021    pub carry_mass_max: f32,
1022    #[serde(default)]
1023    pub encumbrance: EncumbranceState,
1024    /// Keys on the virtual keychain (stowed, zero carry mass).
1025    #[serde(default)]
1026    pub keychain: Vec<ItemStack>,
1027    #[serde(default)]
1028    pub target: Option<CombatTargetHud>,
1029    #[serde(default)]
1030    pub cast: Option<CastProgressHud>,
1031    #[serde(default)]
1032    pub ability_cooldowns: Vec<AbilityCooldownHud>,
1033    #[serde(default)]
1034    pub blocking_active: bool,
1035    /// Live XP pools for the local player (updated every tick with combat HUD).
1036    #[serde(default)]
1037    pub progression_xp: Option<ProgressionXp>,
1038    #[serde(default)]
1039    pub progression_baseline: u16,
1040    #[serde(default)]
1041    pub progression_xp_base: f64,
1042    #[serde(default)]
1043    pub progression_xp_growth: f64,
1044    #[serde(default)]
1045    pub attributes: Option<PrimaryAttributes>,
1046    #[serde(default)]
1047    pub skills: Option<PlayerSkills>,
1048}
1049
1050/// Server → client AOI-filtered entity updates for one sim tick.
1051#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1052pub struct TickDelta {
1053    pub tick: Tick,
1054    pub entities: Vec<EntityState>,
1055    #[serde(default)]
1056    pub resource_nodes: Vec<ResourceNodeView>,
1057    #[serde(default)]
1058    pub buildings: Vec<BuildingView>,
1059    #[serde(default)]
1060    pub doors: Vec<DoorView>,
1061    #[serde(default)]
1062    pub npcs: Vec<NpcView>,
1063    /// Observer inventory stacks (template → qty).
1064    #[serde(default)]
1065    pub inventory: Vec<ItemStack>,
1066    #[serde(default)]
1067    pub blueprints: Vec<BlueprintView>,
1068    #[serde(default)]
1069    pub world_clock: WorldClock,
1070    #[serde(default)]
1071    pub ground_drops: Vec<GroundDropView>,
1072    #[serde(default)]
1073    pub placed_containers: Vec<PlacedContainerView>,
1074    #[serde(default)]
1075    pub combat: Option<CombatHud>,
1076    #[serde(default)]
1077    pub interior_map: Option<InteriorMapView>,
1078    #[serde(default)]
1079    pub quest_log: Vec<QuestLogEntry>,
1080    #[serde(default)]
1081    pub interactables: Vec<InteractableView>,
1082}
1083#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1084pub struct GroundDropView {
1085    pub id: String,
1086    pub template_id: String,
1087    pub quantity: u32,
1088    pub x: f32,
1089    pub y: f32,
1090    pub z: f32,
1091    /// Optional gfx tile id from the item template.
1092    #[serde(default)]
1093    pub tile_id: Option<String>,
1094}
1095
1096/// Full state on region enter or reconnect.
1097#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1098pub struct Snapshot {
1099    pub tick: Tick,
1100    pub chunk_rev: u64,
1101    /// Bumped when blueprints, catalog, segment, or settings reload.
1102    #[serde(default)]
1103    pub content_rev: u64,
1104    /// Stable publish revision from `assets/.content-publish.json` (for client asset sync).
1105    #[serde(default)]
1106    pub publish_rev: u64,
1107    pub entities: Vec<EntityState>,
1108    #[serde(default)]
1109    pub resource_nodes: Vec<ResourceNodeView>,
1110    /// Segment play area width in meters (for HUD).
1111    #[serde(default)]
1112    pub world_width_m: f32,
1113    #[serde(default)]
1114    pub world_height_m: f32,
1115    #[serde(default)]
1116    pub buildings: Vec<BuildingView>,
1117    #[serde(default)]
1118    pub doors: Vec<DoorView>,
1119    #[serde(default)]
1120    pub npcs: Vec<NpcView>,
1121    #[serde(default)]
1122    pub inventory: Vec<ItemStack>,
1123    #[serde(default)]
1124    pub blueprints: Vec<BlueprintView>,
1125    #[serde(default)]
1126    pub world_clock: WorldClock,
1127    #[serde(default)]
1128    pub terrain_zones: Vec<TerrainZoneView>,
1129    #[serde(default)]
1130    pub z_platforms: Vec<ZPlatformView>,
1131    #[serde(default)]
1132    pub z_transitions: Vec<ZTransitionView>,
1133    #[serde(default)]
1134    pub ground_drops: Vec<GroundDropView>,
1135    #[serde(default)]
1136    pub placed_containers: Vec<PlacedContainerView>,
1137    #[serde(default)]
1138    pub combat: Option<CombatHud>,
1139    #[serde(default)]
1140    pub interior_map: Option<InteriorMapView>,
1141    #[serde(default)]
1142    pub quest_log: Vec<QuestLogEntry>,
1143    #[serde(default)]
1144    pub interactables: Vec<InteractableView>,
1145}
1146
1147/// Harvestable node visible to clients.
1148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1149pub struct ResourceNodeView {
1150    pub id: String,
1151    pub label: String,
1152    pub x: f32,
1153    pub y: f32,
1154    pub z: f32,
1155    pub item_template: String,
1156    #[serde(default = "default_node_state")]
1157    pub state: ResourceNodeState,
1158    /// When true, players cannot walk through this node while available/harvesting.
1159    #[serde(default = "default_blocking_view")]
1160    pub blocking: bool,
1161    /// Collision radius for pathfinding (meters).
1162    #[serde(default = "default_blocking_radius_view")]
1163    pub blocking_radius_m: f32,
1164    /// Optional gfx tile id (`resource.oak_log`, …).
1165    #[serde(default)]
1166    pub tile_id: Option<String>,
1167    /// Resolved gfx sprite mode for `tile_id` (server-computed).
1168    #[serde(default)]
1169    pub sprite_mode: Option<String>,
1170    /// Canonical presentation key (`available`, `harvesting`, `depleted`).
1171    #[serde(default)]
1172    pub presentation_state: Option<String>,
1173}
1174
1175fn default_blocking_radius_view() -> f32 {
1176    0.8
1177}
1178
1179fn default_blocking_view() -> bool {
1180    true
1181}
1182
1183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1184#[serde(rename_all = "snake_case")]
1185pub enum ResourceNodeState {
1186    Available,
1187    Harvesting,
1188    Cooldown,
1189}
1190
1191fn default_node_state() -> ResourceNodeState {
1192    ResourceNodeState::Available
1193}
1194
1195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1196pub struct ItemStack {
1197    pub template_id: String,
1198    pub quantity: u32,
1199    /// Stable instance id — preserved across checkpoint/sync when set.
1200    #[serde(default)]
1201    pub item_instance_id: Option<Uuid>,
1202    /// Per-instance metadata (stat rolls, soul-bind, lock ids, etc.).
1203    #[serde(default)]
1204    pub props: BTreeMap<String, String>,
1205    /// Nested contents when this stack is a container instance.
1206    #[serde(default)]
1207    pub contents: Vec<ItemStack>,
1208    /// From item catalog when sent on wire (display only).
1209    #[serde(default)]
1210    pub display_name: Option<String>,
1211    /// From item catalog when sent on wire (`weapon`, `consumable`, …).
1212    #[serde(default)]
1213    pub category: Option<String>,
1214    /// Per-unit mass in kg from catalog (display / move UX).
1215    #[serde(default)]
1216    pub base_mass: Option<f32>,
1217    /// Per-unit volume from catalog (display / move UX).
1218    #[serde(default)]
1219    pub base_volume: Option<f32>,
1220    /// Container capacity when this stack is a container template.
1221    #[serde(default)]
1222    pub capacity_volume: Option<f32>,
1223    /// Whether the template stacks in inventory (from catalog).
1224    #[serde(default)]
1225    pub stackable: Option<bool>,
1226}
1227
1228impl ItemStack {
1229    pub fn simple(template_id: impl Into<String>, quantity: u32) -> Self {
1230        Self {
1231            template_id: template_id.into(),
1232            quantity,
1233            item_instance_id: None,
1234            props: BTreeMap::new(),
1235            contents: Vec::new(),
1236            display_name: None,
1237            category: None,
1238            base_mass: None,
1239            base_volume: None,
1240            capacity_volume: None,
1241            stackable: None,
1242        }
1243    }
1244}
1245
1246/// Carry encumbrance band (`plans/08` §3.2).
1247#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1248#[serde(rename_all = "snake_case")]
1249pub enum EncumbranceState {
1250    #[default]
1251    Light,
1252    Heavy,
1253    Over,
1254}
1255
1256/// Body region a wearable item occupies — at most one item equipped per slot.
1257/// `Head`/`Body`/`Arms`/`Legs`/`Feet` are reserved for future armor (`plans/11`); only
1258/// `Back` (backpack) and `Waist` (belt) have equippable items today (`plans/08` §4.1).
1259#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)]
1260#[serde(rename_all = "snake_case")]
1261pub enum BodySlot {
1262    Head,
1263    Body,
1264    Arms,
1265    Legs,
1266    Feet,
1267    Back,
1268    Waist,
1269}
1270
1271/// Where an item lives for `MoveItem` / open-container UX.
1272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1273#[serde(rename_all = "snake_case")]
1274pub enum InventoryLocation {
1275    /// Loose on-person inventory (not inside a worn/placed container).
1276    Root,
1277    /// Inside a worn item (backpack contents, or a pouch clipped onto a worn belt).
1278    Worn { slot: BodySlot },
1279    /// Inside a world-placed container.
1280    Placed { container_id: String },
1281    /// Virtual key ring — only `container_key` items; zero carry mass; persists with combat profile.
1282    Keychain,
1283}
1284
1285/// AOI view of a placeable chest on the map.
1286#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1287pub struct PlacedContainerView {
1288    pub id: String,
1289    pub template_id: String,
1290    pub display_name: String,
1291    pub x: f32,
1292    pub y: f32,
1293    pub z: f32,
1294    pub locked: bool,
1295    /// Observer can open (unlocked, or holds matching key).
1296    #[serde(default)]
1297    pub accessible: bool,
1298    #[serde(default)]
1299    pub owner_character_id: Option<Uuid>,
1300    /// Nested contents when `accessible` (empty when locked without key).
1301    #[serde(default)]
1302    pub contents: Vec<ItemStack>,
1303    /// Lock id for matching `container_key` props (`opens_lock_id`).
1304    #[serde(default)]
1305    pub lock_id: Option<String>,
1306    /// Internal storage capacity (liters) from item catalog.
1307    #[serde(default)]
1308    pub capacity_volume: Option<f32>,
1309    /// Container instance id (for MoveItem parent targeting).
1310    #[serde(default)]
1311    pub item_instance_id: Option<Uuid>,
1312    /// Optional gfx tile id from the item template.
1313    #[serde(default)]
1314    pub tile_id: Option<String>,
1315}
1316
1317#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1318pub struct BlueprintIngredientView {
1319    pub template_id: String,
1320    pub quantity: u32,
1321    /// Always serialize — `true` is not bool::default() so postcard keeps it; explicit for clarity.
1322    pub consumed: bool,
1323}
1324
1325#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1326pub struct ToolRequirementView {
1327    pub item: String,
1328    /// Always serialize — postcard omits `false` by default, which breaks roundtrip without explicit value.
1329    pub consumed: bool,
1330}
1331
1332#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1333pub struct SkillRequirementView {
1334    pub skill: String,
1335    pub level: u32,
1336}
1337
1338#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1339pub struct BlueprintView {
1340    pub id: String,
1341    pub label: String,
1342    pub output: String,
1343    pub output_qty: u32,
1344    pub craft_ticks: u32,
1345    pub inputs: Vec<BlueprintIngredientView>,
1346    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
1347    #[serde(default)]
1348    pub station: Option<String>,
1349    #[serde(default)]
1350    pub category: Option<String>,
1351    #[serde(default)]
1352    pub required_tools: Vec<ToolRequirementView>,
1353    #[serde(default)]
1354    pub skill: Option<SkillRequirementView>,
1355    #[serde(default)]
1356    pub failure_chance: f32,
1357}
1358
1359/// Terrain overlay from segment YAML (`terrain_zones`).
1360#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1361#[serde(rename_all = "snake_case")]
1362pub enum TerrainKindView {
1363    #[default]
1364    Grass,
1365    Hill,
1366    Bog,
1367    ShallowWater,
1368    DeepWater,
1369    Trail,
1370    Rock,
1371}
1372
1373#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1374pub struct TerrainZoneView {
1375    pub id: String,
1376    pub x0: f32,
1377    pub y0: f32,
1378    pub x1: f32,
1379    pub y1: f32,
1380    #[serde(default)]
1381    pub kind: TerrainKindView,
1382    /// Ground elevation at this zone (m).
1383    #[serde(default)]
1384    pub elevation: f32,
1385    /// Optional map glyph override (single character); falls back to terrain kind catalog.
1386    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
1387    #[serde(default)]
1388    pub glyph: Option<String>,
1389    /// Optional color (`#RRGGBB` or ratatui name); falls back to kind / elevation tint.
1390    #[serde(default)]
1391    pub color: Option<String>,
1392    /// Optional gfx tile id (`terrain.grass`, …) — presentation only.
1393    #[serde(default)]
1394    pub tile_id: Option<String>,
1395    /// Overlap priority — higher wins (`segment.terrain_zones`).
1396    #[serde(default)]
1397    pub z_order: i32,
1398}
1399
1400/// Walkable platform at a fixed z (`segment.z_platforms`).
1401#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1402pub struct ZPlatformView {
1403    pub id: String,
1404    pub z: f32,
1405    pub x0: f32,
1406    pub y0: f32,
1407    pub x1: f32,
1408    pub y1: f32,
1409}
1410
1411/// Stairs / ramp linking two z bands (`segment.z_transitions`).
1412#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1413pub struct ZTransitionView {
1414    pub id: String,
1415    pub z_from: f32,
1416    pub z_to: f32,
1417    pub x0: f32,
1418    pub y0: f32,
1419    pub x1: f32,
1420    pub y1: f32,
1421}
1422
1423#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1424pub struct BuildingView {
1425    pub id: String,
1426    pub label: String,
1427    pub x: f32,
1428    pub y: f32,
1429    pub width_m: f32,
1430    pub depth_m: f32,
1431    #[serde(default)]
1432    pub interior_blueprint: Option<String>,
1433    #[serde(default)]
1434    pub tags: Vec<String>,
1435}
1436
1437#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1438pub struct DoorView {
1439    pub id: String,
1440    pub building_id: String,
1441    pub x: f32,
1442    pub y: f32,
1443    #[serde(default)]
1444    pub open: bool,
1445    #[serde(default)]
1446    pub portal: Option<String>,
1447}
1448
1449#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1450pub struct InteriorRoomView {
1451    pub id: String,
1452    pub label: String,
1453    pub floor: i32,
1454    pub x0: f32,
1455    pub y0: f32,
1456    pub x1: f32,
1457    pub y1: f32,
1458    #[serde(default)]
1459    pub floor_color: Option<String>,
1460    #[serde(default)]
1461    pub floor_glyph: Option<String>,
1462}
1463
1464#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1465pub struct InteriorDoorView {
1466    pub id: String,
1467    pub room_a: String,
1468    pub room_b: String,
1469    pub x: f32,
1470    pub y: f32,
1471    pub kind: String,
1472}
1473
1474#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1475pub struct InteriorMapView {
1476    pub building_id: String,
1477    pub blueprint_id: String,
1478    pub background_color: String,
1479    #[serde(default)]
1480    pub default_floor_color: Option<String>,
1481    #[serde(default = "default_floor_height_view")]
1482    pub floor_height_m: f32,
1483    pub rooms: Vec<InteriorRoomView>,
1484    #[serde(default)]
1485    pub room_doors: Vec<InteriorDoorView>,
1486}
1487
1488fn default_floor_height_view() -> f32 {
1489    3.0
1490}
1491
1492#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1493pub struct NpcView {
1494    pub id: String,
1495    pub label: String,
1496    pub role: String,
1497    pub x: f32,
1498    pub y: f32,
1499    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
1500    #[serde(default)]
1501    pub building_id: Option<String>,
1502    /// Authoritative sim entity for wildlife / combat targets.
1503    #[serde(default)]
1504    pub entity_id: Option<EntityId>,
1505    #[serde(default)]
1506    pub life_state: Option<LifeState>,
1507    #[serde(default)]
1508    pub hp_pct: Option<f32>,
1509    /// True when this NPC has buy/sell/teach offers (`npc_has_market`).
1510    #[serde(default)]
1511    pub can_trade: bool,
1512    /// Gfx sprite sheet id (`assets/gfx/sprites/`). Client falls back to `npc.{id}` / `npc.{role}`.
1513    #[serde(default)]
1514    pub tile_id: Option<String>,
1515    /// Wildlife FSM state (`idle`, `chase`, `combat`, …) when behavior-driven (debug).
1516    #[serde(default)]
1517    pub behavior_state: Option<String>,
1518    /// Canonical gfx presentation key (`combat`, `pursue`, `walking`, `talking`, …).
1519    #[serde(default)]
1520    pub presentation_state: Option<String>,
1521    /// Resolved gfx sprite mode for `tile_id` (server-computed).
1522    #[serde(default)]
1523    pub sprite_mode: Option<String>,
1524}
1525
1526#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1527pub struct UseResult {
1528    pub template_id: String,
1529    pub hunger_restored: f32,
1530    pub thirst_restored: f32,
1531    #[serde(default)]
1532    pub health_restored: f32,
1533    #[serde(default)]
1534    pub mana_restored: f32,
1535    #[serde(default)]
1536    pub cleared_dot_ids: Vec<String>,
1537}
1538
1539#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1540pub struct CraftResult {
1541    pub blueprint_id: String,
1542    pub outputs: Vec<ItemStack>,
1543    pub consumed: Vec<ItemStack>,
1544    /// 1-based index within the submitted batch (1 when not batching).
1545    #[serde(default = "default_one")]
1546    pub batch_index: u32,
1547    /// Total crafts requested in this batch (1 when not batching).
1548    #[serde(default = "default_one")]
1549    pub batch_total: u32,
1550}
1551
1552fn default_one() -> u32 {
1553    1
1554}
1555
1556#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1557pub struct DeathNotice {
1558    pub entity_id: EntityId,
1559    pub respawn_x: f32,
1560    pub respawn_y: f32,
1561    pub message: String,
1562}
1563
1564#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1565pub struct InteractionNotice {
1566    pub target_id: String,
1567    pub message: String,
1568    #[serde(default)]
1569    pub coins_delta: i32,
1570    #[serde(default)]
1571    pub inventory_delta: Vec<ItemStack>,
1572}
1573
1574#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1575#[serde(rename_all = "snake_case")]
1576pub enum NpcTalkTrustFlag {
1577    Stranger,
1578    Acquainted,
1579    Trusted,
1580}
1581
1582#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1583pub struct NpcTalkOpened {
1584    pub npc_id: String,
1585    pub npc_label: String,
1586    pub greeting: String,
1587    pub trust_flag: NpcTalkTrustFlag,
1588}
1589
1590#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1591pub struct NpcTalkPending {
1592    pub npc_id: String,
1593}
1594
1595#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1596pub struct NpcTalkReply {
1597    pub npc_id: String,
1598    pub line: String,
1599    pub trust_flag: NpcTalkTrustFlag,
1600}
1601
1602#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1603pub struct NpcTalkClosed {
1604    pub npc_id: String,
1605}
1606
1607#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1608pub struct NpcTalkError {
1609    pub npc_id: String,
1610    pub reason: String,
1611}
1612
1613#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1614#[serde(rename_all = "snake_case")]
1615pub enum QuestStatusView {
1616    Available,
1617    Active,
1618    Completed,
1619}
1620
1621#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1622pub struct QuestObjectiveProgress {
1623    pub label: String,
1624    pub current: u32,
1625    pub required: u32,
1626    pub done: bool,
1627}
1628
1629#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1630pub struct QuestLogEntry {
1631    pub quest_id: String,
1632    pub title: String,
1633    pub description: String,
1634    pub status: QuestStatusView,
1635    #[serde(default)]
1636    pub current_step_id: Option<String>,
1637    #[serde(default)]
1638    pub current_step_title: String,
1639    #[serde(default)]
1640    pub objectives: Vec<QuestObjectiveProgress>,
1641    #[serde(default)]
1642    pub is_tracked: bool,
1643    #[serde(default)]
1644    pub can_withdraw: bool,
1645}
1646
1647#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1648pub struct InteractableView {
1649    pub id: String,
1650    pub kind: String,
1651    pub label: String,
1652    pub x: f32,
1653    pub y: f32,
1654    pub z: f32,
1655    #[serde(default)]
1656    pub board_id: Option<String>,
1657}
1658
1659#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1660pub struct QuestOffer {
1661    pub quest_id: String,
1662    pub title: String,
1663    pub description: String,
1664    #[serde(default)]
1665    pub step_count: u32,
1666}
1667
1668#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1669pub struct QuestNotice {
1670    pub quest_id: String,
1671    pub title: String,
1672    pub message: String,
1673}
1674
1675#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1676#[serde(rename_all = "snake_case")]
1677pub enum ShopOfferKind {
1678    Item,
1679    Blueprint,
1680}
1681
1682#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1683pub struct ShopOffer {
1684    pub offer_id: String,
1685    pub kind: ShopOfferKind,
1686    pub label: String,
1687    #[serde(default)]
1688    pub template_id: Option<String>,
1689    #[serde(default)]
1690    pub blueprint_id: Option<String>,
1691    pub price_copper: u32,
1692    #[serde(default)]
1693    pub affordable: bool,
1694    #[serde(default)]
1695    pub already_owned: bool,
1696}
1697
1698#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1699pub struct ShopBuyLine {
1700    pub template_id: String,
1701    pub label: String,
1702    pub quantity: u32,
1703    pub price_copper: u32,
1704}
1705
1706#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1707pub struct ShopCatalog {
1708    pub npc_id: String,
1709    pub npc_label: String,
1710    #[serde(default)]
1711    pub sells: Vec<ShopOffer>,
1712    #[serde(default)]
1713    pub buys: Vec<ShopBuyLine>,
1714}
1715
1716#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1717pub struct HarvestResult {
1718    pub node_id: String,
1719    /// Stack quantity granted (not one-node-one-instance).
1720    pub quantity: u32,
1721    pub item_template: String,
1722    /// Optional DB row id when persisted to control plane.
1723    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
1724    #[serde(default)]
1725    pub item_instance_id: Option<Uuid>,
1726}
1727
1728/// Every on-wire payload is wrapped for versioning and codec uniformity.
1729#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1730pub struct Envelope<T> {
1731    pub protocol_version: u16,
1732    pub payload: T,
1733}
1734
1735impl<T> Envelope<T> {
1736    pub fn new(payload: T) -> Self {
1737        Self {
1738            protocol_version: crate::PROTOCOL_VERSION,
1739            payload,
1740        }
1741    }
1742}
1743
1744/// Session handshake after transport connect.
1745#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1746pub struct Hello {
1747    pub client_name: String,
1748    pub protocol_version: u16,
1749    #[serde(default)]
1750    pub auth: AuthCredential,
1751    /// Required for session auth; embedded in `ApiToken` variant otherwise.
1752    #[serde(default)]
1753    pub character_id: Option<Uuid>,
1754}
1755
1756/// How the client authenticates to the game gateway.
1757/// Uses default serde enum encoding (postcard-compatible; not internally tagged).
1758#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1759#[serde(rename_all = "snake_case")]
1760pub enum AuthCredential {
1761    DevLocal,
1762    Session { token: String },
1763    ApiToken { token: String, character_id: Uuid },
1764}
1765
1766impl Default for AuthCredential {
1767    fn default() -> Self {
1768        Self::DevLocal
1769    }
1770}
1771
1772#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1773pub struct Welcome {
1774    pub session_id: SessionId,
1775    pub entity_id: EntityId,
1776    pub snapshot: Snapshot,
1777}
1778
1779#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1780pub enum ServerMessage {
1781    Welcome(Welcome),
1782    /// Static world layers refreshed (blueprints, catalog, map, terrain) — no client restart.
1783    ContentUpdated(Snapshot),
1784    Tick(TickDelta),
1785    IntentAck {
1786        entity_id: EntityId,
1787        seq: Seq,
1788        tick: Tick,
1789    },
1790    Chat(ChatMessage),
1791    HarvestResult(HarvestResult),
1792    UseResult(UseResult),
1793    CraftResult(CraftResult),
1794    Death(DeathNotice),
1795    Interaction(InteractionNotice),
1796    ShopOpened(ShopCatalog),
1797    NpcTalkOpened(NpcTalkOpened),
1798    NpcTalkPending(NpcTalkPending),
1799    NpcTalkReply(NpcTalkReply),
1800    NpcTalkClosed(NpcTalkClosed),
1801    NpcTalkError(NpcTalkError),
1802    QuestOffer(QuestOffer),
1803    QuestAccepted(QuestNotice),
1804    QuestWithdrawn(QuestNotice),
1805    QuestStepCompleted(QuestNotice),
1806    QuestCompleted(QuestNotice),
1807}
1808
1809#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1810pub enum ClientMessage {
1811    Hello(Hello),
1812    Intent(Intent),
1813    Disconnect,
1814}
1815
1816#[cfg(test)]
1817mod tests {
1818    use super::*;
1819
1820    #[test]
1821    fn pristine_vitals_state_yields_full_pools() {
1822        let attrs = PrimaryAttributes::default();
1823        let vitals = StoredVitalsState::default().apply_to(attrs);
1824        assert!(vitals.health > 0.0);
1825        assert_eq!(vitals.health, vitals.health_max);
1826        assert!((vitals.mana_max - 61.0).abs() < 0.01);
1827    }
1828
1829    #[test]
1830    fn saved_vitals_scale_when_pool_max_increases() {
1831        let mut attrs = PrimaryAttributes::default();
1832        attrs.intelligence = 140;
1833        attrs.wisdom = 140;
1834        let saved = StoredVitalsState {
1835            health: 100.0,
1836            mana: 14.0,
1837            stamina: 100.0,
1838            ..StoredVitalsState::default()
1839        };
1840        let vitals = saved.apply_to(attrs);
1841        assert!(vitals.mana_max > 55.0);
1842        assert!(
1843            (vitals.mana - vitals.mana_max).abs() < 0.01,
1844            "full legacy mana bar migrates to full new bar"
1845        );
1846    }
1847
1848    #[test]
1849    fn empty_vitals_state_is_pristine() {
1850        let pristine = StoredVitalsState {
1851            health: 0.0,
1852            mana: 0.0,
1853            stamina: 0.0,
1854            hunger: 0.0,
1855            thirst: 0.0,
1856            coins: 0,
1857            deaths: 0,
1858            life_state: LifeState::Alive,
1859        };
1860        assert!(pristine.is_pristine());
1861        let vitals = pristine.apply_to(PrimaryAttributes::default());
1862        assert!(vitals.health > 0.0);
1863    }
1864
1865    #[test]
1866    fn stored_vitals_roundtrip_preserves_partial_pools() {
1867        let attrs = PrimaryAttributes::default();
1868        let mut live = PlayerVitals::from_attributes(attrs);
1869        live.health = 25.0;
1870        live.hunger = 77.0;
1871        live.deaths = 2;
1872        let stored = StoredVitalsState::from_live(&live);
1873        let restored = stored.apply_to(attrs);
1874        assert!(
1875            (restored.health - 25.0).abs() < 0.01,
1876            "partial HP below cap stays absolute"
1877        );
1878        assert_eq!(restored.hunger, 77.0);
1879        assert_eq!(restored.deaths, 2);
1880    }
1881
1882    #[test]
1883    fn skill_tiers_start_at_zero() {
1884        let skill = SkillProgress::default();
1885        assert_eq!(skill.level, 0);
1886        assert_eq!(skill.display_tier(), 0);
1887        let trained = SkillProgress {
1888            level: 250,
1889            last_trained_tick: 1,
1890        };
1891        assert_eq!(trained.display_tier(), 2);
1892    }
1893
1894    #[test]
1895    fn quest_server_messages_roundtrip_json() {
1896        use crate::codec::{Codec, PostcardCodec};
1897
1898        let offer = ServerMessage::QuestOffer(QuestOffer {
1899            quest_id: "ada_goblin_hunt".into(),
1900            title: "Goblin Trouble".into(),
1901            description: "Help Ada".into(),
1902            step_count: 3,
1903        });
1904        let notice = ServerMessage::QuestAccepted(QuestNotice {
1905            quest_id: "ada_goblin_hunt".into(),
1906            title: "Goblin Trouble".into(),
1907            message: "Quest accepted".into(),
1908        });
1909        for msg in [offer, notice] {
1910            let bytes = PostcardCodec.encode(&msg).unwrap();
1911            let decoded: ServerMessage = PostcardCodec.decode(&bytes).unwrap();
1912            assert_eq!(decoded, msg);
1913        }
1914    }
1915}