Skip to main content

flatland_protocol/
types.rs

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