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 offhand (shield / dual-wield). Cleared when mainhand is two-handed.
508    #[serde(default)]
509    pub offhand_template_id: Option<String>,
510    /// Equipped body-slot items (backpack, belt, armor, jewelry), nested contents included
511    /// (e.g. pouches clipped onto a worn belt). At most one entry per `BodySlot`.
512    #[serde(default)]
513    pub worn: Vec<(BodySlot, ItemStack)>,
514    /// Player-authored rotation library (`plans/26` §C3).
515    #[serde(default)]
516    pub rotation_presets: Vec<RotationPreset>,
517    /// Per-target slot state (T1/T2).
518    #[serde(default)]
519    pub target_slots: Vec<StoredTargetSlot>,
520    /// Discovered craft recipe ids (`plans/09` §C2 hybrid discovery).
521    #[serde(default)]
522    pub known_blueprint_ids: Vec<String>,
523    /// Keys stowed on the keychain (not counted toward carry mass).
524    #[serde(default)]
525    pub keychain: Vec<ItemStack>,
526}
527
528fn default_auto_attack() -> bool {
529    true
530}
531
532impl Default for StoredCombatProfile {
533    fn default() -> Self {
534        Self {
535            combat_target_instance_id: None,
536            in_combat: false,
537            last_combat_tick: 0,
538            last_attack_tick: 0,
539            cooldowns_until_tick: BTreeMap::new(),
540            auto_attack_enabled: true,
541            mainhand_template_id: None,
542            offhand_template_id: None,
543            worn: Vec::new(),
544            rotation_presets: Vec::new(),
545            target_slots: Vec::new(),
546            known_blueprint_ids: Vec::new(),
547            keychain: Vec::new(),
548        }
549    }
550}
551
552#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
553pub struct EntityState {
554    pub id: EntityId,
555    pub transform: Transform,
556    /// Character / display name (first letter used as map glyph for other players).
557    #[serde(default)]
558    pub label: String,
559    #[serde(default)]
560    pub vitals: Option<PlayerVitals>,
561    /// Primary attributes (local player / inspect).
562    #[serde(default)]
563    pub attributes: Option<PrimaryAttributes>,
564    #[serde(default)]
565    pub skills: Option<PlayerSkills>,
566    /// When inside a building interior (players only).
567    #[serde(default)]
568    pub inside_building: Option<String>,
569    /// Gfx sprite sheet id for player avatars (`player.hero`, …).
570    #[serde(default)]
571    pub tile_id: Option<String>,
572    /// Canonical gfx presentation key (`walking`, `combat`, `harvesting`, …).
573    #[serde(default)]
574    pub presentation_state: Option<String>,
575    /// Resolved gfx sprite mode for `tile_id` (server-computed).
576    #[serde(default)]
577    pub sprite_mode: Option<String>,
578    /// XP pools for local player progression display (omitted on NPCs / other players).
579    #[serde(default)]
580    pub progression_xp: Option<ProgressionXp>,
581}
582
583/// Nearby voice chat vs magical long-range (requires whisper-stone item).
584#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
585#[serde(rename_all = "snake_case")]
586pub enum ChatChannel {
587    Nearby,
588    WhisperStone,
589}
590
591#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
592pub struct ChatMessage {
593    pub channel: ChatChannel,
594    pub from_entity: EntityId,
595    pub from_name: String,
596    pub text: String,
597    pub tick: Tick,
598}
599
600/// Client → server gameplay input (reliable, sequenced per entity).
601#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
602pub enum Intent {
603    Move {
604        entity_id: EntityId,
605        forward: f32,
606        strafe: f32,
607        /// Climb (+) or descend (−) on z axis (m/s intent).
608        #[serde(default)]
609        vertical: f32,
610        /// Sprint multiplier when stamina allows.
611        #[serde(default)]
612        sprint: bool,
613        seq: Seq,
614    },
615    Stop {
616        entity_id: EntityId,
617        seq: Seq,
618    },
619    Harvest {
620        entity_id: EntityId,
621        node_id: String,
622        seq: Seq,
623    },
624    Use {
625        entity_id: EntityId,
626        template_id: String,
627        seq: Seq,
628    },
629    Say {
630        entity_id: EntityId,
631        channel: ChatChannel,
632        text: String,
633        seq: Seq,
634    },
635    /// Start a blueprint craft (timed, like harvest).
636    Craft {
637        entity_id: EntityId,
638        blueprint_id: String,
639        /// Batches to run back-to-back; `None` crafts as many as materials/stamina allow.
640        #[serde(default)]
641        count: Option<u32>,
642        seq: Seq,
643    },
644    /// Door, NPC, enter/exit building.
645    Interact {
646        entity_id: EntityId,
647        target_id: String,
648        seq: Seq,
649    },
650    /// Buy from an NPC shop offer (`ShopOpened` catalog).
651    ShopBuy {
652        entity_id: EntityId,
653        npc_id: String,
654        offer_id: String,
655        #[serde(default = "default_one")]
656        quantity: u32,
657        seq: Seq,
658    },
659    /// Sell inventory to an NPC (`ShopOpened` buy list).
660    ShopSell {
661        entity_id: EntityId,
662        npc_id: String,
663        template_id: String,
664        #[serde(default = "default_one")]
665        quantity: u32,
666        seq: Seq,
667    },
668    /// Dev / test: apply damage to self (co-op testing).
669    TestDamage {
670        entity_id: EntityId,
671        amount: f32,
672        seq: Seq,
673    },
674    /// Slot-1 combat target (`plans/12` alias).
675    SetTarget {
676        entity_id: EntityId,
677        target_id: EntityId,
678        seq: Seq,
679    },
680    /// Target slot assignment (`plans/12` §5.3). Slot 1 aliases `SetTarget`.
681    SetTargetSlot {
682        entity_id: EntityId,
683        slot_index: u8,
684        target_id: EntityId,
685        seq: Seq,
686    },
687    ClearTarget {
688        entity_id: EntityId,
689        seq: Seq,
690    },
691    ClearTargetSlot {
692        entity_id: EntityId,
693        slot_index: u8,
694        seq: Seq,
695    },
696    /// Toggle per-slot auto-attack (`plans/12` §4.2).
697    SetAutoAttack {
698        entity_id: EntityId,
699        slot_index: u8,
700        enabled: bool,
701        seq: Seq,
702    },
703    /// Melee attack — uses `target_id` or the player's current target.
704    Attack {
705        entity_id: EntityId,
706        #[serde(default)]
707        target_id: Option<EntityId>,
708        #[serde(default)]
709        weapon_slot: Option<u32>,
710        seq: Seq,
711    },
712    /// Pick up a ground loot pile (nearest in range when `drop_id` omitted).
713    Pickup {
714        entity_id: EntityId,
715        #[serde(default)]
716        drop_id: Option<String>,
717        seq: Seq,
718    },
719    /// Cast a spell or use a non-weapon ability template (`plans/24` §4.3b).
720    Cast {
721        entity_id: EntityId,
722        ability_id: String,
723        target_id: EntityId,
724        seq: Seq,
725    },
726    /// Bind an ability to a target slot action bar (`plans/12` §4.2).
727    BindActionSlot {
728        entity_id: EntityId,
729        slot_index: u8,
730        ability_id: String,
731        #[serde(default = "default_auto_attack")]
732        auto_enabled: bool,
733        seq: Seq,
734    },
735    /// Fire a bound consumable or ability from a slot (`plans/24` §4.3c).
736    UseActionSlot {
737        entity_id: EntityId,
738        slot_index: u8,
739        seq: Seq,
740    },
741    /// Dodge — brief i-frames, stamina cost (`plans/24` §4.3c).
742    Dodge {
743        entity_id: EntityId,
744        seq: Seq,
745    },
746    /// Lunge — burst forward in movement/facing direction, higher stamina cost.
747    Lunge {
748        entity_id: EntityId,
749        /// Last movement forward axis when idle (−1..1).
750        #[serde(default)]
751        forward: f32,
752        /// Last movement strafe axis when idle (−1..1).
753        #[serde(default)]
754        strafe: f32,
755        seq: Seq,
756    },
757    /// Directional jump — leap one cell uphill over a medium cliff (`plans/04` §4.5b).
758    DirectionalJump {
759        entity_id: EntityId,
760        /// Jump direction forward axis (−1..1).
761        #[serde(default)]
762        forward: f32,
763        /// Jump direction strafe axis (−1..1).
764        #[serde(default)]
765        strafe: f32,
766        seq: Seq,
767    },
768    /// Block — frontal mitigation while held (`plans/24` §4.3c).
769    Block {
770        entity_id: EntityId,
771        #[serde(default = "default_block_enabled")]
772        enabled: bool,
773        seq: Seq,
774    },
775    /// Equip or clear mainhand weapon (`plans/26` §C2b). `None` unequips.
776    /// Two-handed weapons (`hand_slots: 2`) clear offhand on equip.
777    EquipMainhand {
778        entity_id: EntityId,
779        #[serde(default)]
780        template_id: Option<String>,
781        seq: Seq,
782    },
783    /// Equip or clear offhand (shield / dual-wield). Rejected while mainhand is two-handed.
784    EquipOffhand {
785        entity_id: EntityId,
786        #[serde(default)]
787        template_id: Option<String>,
788        seq: Seq,
789    },
790    /// Equip a wearable (container, belt, armor, jewelry) to a body slot, or clear the
791    /// slot when `instance_id` is None.
792    EquipWorn {
793        entity_id: EntityId,
794        slot: BodySlot,
795        #[serde(default)]
796        instance_id: Option<Uuid>,
797        seq: Seq,
798    },
799    /// Move an item instance between root / worn / placed container inventories.
800    MoveItem {
801        entity_id: EntityId,
802        item_instance_id: Uuid,
803        from: InventoryLocation,
804        to: InventoryLocation,
805        /// When moving into a container location, nest under this parent instance (None = container root).
806        #[serde(default)]
807        to_parent_instance_id: Option<Uuid>,
808        /// Units to move; `None` moves the whole stack. Server clamps to volume/carry limits.
809        #[serde(default)]
810        quantity: Option<u32>,
811        seq: Seq,
812    },
813    /// Place a placeable container from inventory onto the ground at the player's feet.
814    PlaceContainer {
815        entity_id: EntityId,
816        item_instance_id: Uuid,
817        seq: Seq,
818    },
819    /// Pick up a placed container (with contents) into inventory.
820    PickupContainer {
821        entity_id: EntityId,
822        container_id: String,
823        seq: Seq,
824    },
825    /// Lock or unlock a worn or placed container (requires matching key when locking/unlocking).
826    SetContainerLocked {
827        entity_id: EntityId,
828        /// Worn slot or placed container id encoded as location.
829        location: InventoryLocation,
830        locked: bool,
831        seq: Seq,
832    },
833    /// Drop a non-container item on the ground at the player's feet.
834    DropItem {
835        entity_id: EntityId,
836        item_instance_id: Uuid,
837        from: InventoryLocation,
838        seq: Seq,
839    },
840    /// Permanently destroy an item stack (not recoverable; no ground drop).
841    DestroyItem {
842        entity_id: EntityId,
843        item_instance_id: Uuid,
844        from: InventoryLocation,
845        /// Units to destroy; `None` destroys the whole stack.
846        #[serde(default)]
847        quantity: Option<u32>,
848        seq: Seq,
849    },
850    /// Set a custom display name on a container instance (chest, pouch, backpack).
851    RenameContainer {
852        entity_id: EntityId,
853        item_instance_id: Uuid,
854        location: InventoryLocation,
855        name: String,
856        seq: Seq,
857    },
858    /// Create or update a rotation preset in the player's library.
859    UpsertRotationPreset {
860        entity_id: EntityId,
861        preset: RotationPreset,
862        seq: Seq,
863    },
864    /// Remove a rotation preset from the library.
865    DeleteRotationPreset {
866        entity_id: EntityId,
867        preset_id: String,
868        seq: Seq,
869    },
870    /// Assign a library preset to target slot T1/T2.
871    AssignSlotPreset {
872        entity_id: EntityId,
873        slot_index: u8,
874        preset_id: String,
875        seq: Seq,
876    },
877    /// Fire the next ready ability in a slot's rotation (manual step).
878    AdvanceRotation {
879        entity_id: EntityId,
880        slot_index: u8,
881        seq: Seq,
882    },
883    /// Open a turn-based conversation with an NPC.
884    NpcTalkOpen {
885        entity_id: EntityId,
886        npc_id: String,
887        seq: Seq,
888    },
889    /// Send a player message in an open NPC conversation.
890    NpcTalkSay {
891        entity_id: EntityId,
892        npc_id: String,
893        message: String,
894        seq: Seq,
895    },
896    /// Close an NPC conversation.
897    NpcTalkClose {
898        entity_id: EntityId,
899        npc_id: String,
900        seq: Seq,
901    },
902    /// Accept a discovered quest (adds to active list).
903    AcceptQuest {
904        entity_id: EntityId,
905        quest_id: String,
906        seq: Seq,
907    },
908    /// Withdraw from an active quest (resets progress; can re-accept).
909    WithdrawQuest {
910        entity_id: EntityId,
911        quest_id: String,
912        seq: Seq,
913    },
914    /// Highlight an active quest in the HUD.
915    TrackQuest {
916        entity_id: EntityId,
917        quest_id: String,
918        seq: Seq,
919    },
920    /// Turn in items for an active give_item objective while near an NPC.
921    QuestGiveItem {
922        entity_id: EntityId,
923        npc_id: String,
924        template_id: String,
925        #[serde(default = "default_one")]
926        quantity: u32,
927        seq: Seq,
928    },
929    /// Hire an NPC worker (fee + recurring wage).
930    HireWorker {
931        entity_id: EntityId,
932        def_id: String,
933        wage_copper_per_interval: u32,
934        #[serde(default)]
935        lodging_container_id: Option<String>,
936        #[serde(default)]
937        job_yaml: Option<String>,
938        seq: Seq,
939    },
940    /// Release a hired worker instance.
941    DismissWorker {
942        entity_id: EntityId,
943        worker_instance_id: String,
944        seq: Seq,
945    },
946    /// Replace or assign a worker job YAML loop.
947    SetWorkerJob {
948        entity_id: EntityId,
949        worker_instance_id: String,
950        job_yaml: String,
951        seq: Seq,
952    },
953    /// Point a worker at a camp bed / lodging container.
954    AssignWorkerLodging {
955        entity_id: EntityId,
956        worker_instance_id: String,
957        lodging_container_id: String,
958        seq: Seq,
959    },
960    /// Switch companion vs job-loop automation mode.
961    SetWorkerMode {
962        entity_id: EntityId,
963        worker_instance_id: String,
964        mode: String,
965        seq: Seq,
966    },
967    /// Hand an item from the player's inventory to a hired worker (e.g. a tool the
968    /// worker must carry but not consume, like a handsaw for `oak_to_lumber`).
969    GiveWorkerItem {
970        entity_id: EntityId,
971        worker_instance_id: String,
972        item_instance_id: uuid::Uuid,
973        #[serde(default)]
974        quantity: Option<u32>,
975        seq: Seq,
976    },
977    /// Take an item from a hired worker's inventory back into the employer's root.
978    TakeWorkerItem {
979        entity_id: EntityId,
980        worker_instance_id: String,
981        item_instance_id: uuid::Uuid,
982        #[serde(default)]
983        quantity: Option<u32>,
984        seq: Seq,
985    },
986    /// Set a custom display name for a hired worker (shown in menus / route editor).
987    RenameHiredWorker {
988        entity_id: EntityId,
989        worker_instance_id: String,
990        name: String,
991        seq: Seq,
992    },
993    /// Teach a known blueprint to a hired worker (costs `worker_train_copper`).
994    TeachWorkerBlueprint {
995        entity_id: EntityId,
996        worker_instance_id: String,
997        blueprint_id: String,
998        seq: Seq,
999    },
1000}
1001
1002fn default_block_enabled() -> bool {
1003    true
1004}
1005
1006/// Observer combat HUD (local player only).
1007#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1008pub struct CombatTargetHud {
1009    pub entity_id: EntityId,
1010    #[serde(default)]
1011    pub label: String,
1012    #[serde(default)]
1013    pub level: u32,
1014    pub health: f32,
1015    pub health_max: f32,
1016    #[serde(default)]
1017    pub life_state: LifeState,
1018    #[serde(default)]
1019    pub distance_m: f32,
1020}
1021
1022/// Active spell cast channel progress.
1023#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1024pub struct CastProgressHud {
1025    #[serde(default)]
1026    pub ability_id: String,
1027    #[serde(default)]
1028    pub ability_label: String,
1029    #[serde(default)]
1030    pub ticks_remaining: u64,
1031    #[serde(default)]
1032    pub ticks_total: u64,
1033}
1034
1035/// Cooldown state for a combat ability shown on the action bar.
1036#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1037pub struct AbilityCooldownHud {
1038    #[serde(default)]
1039    pub ability_id: String,
1040    #[serde(default)]
1041    pub label: String,
1042    #[serde(default)]
1043    pub cd_ticks: u64,
1044    #[serde(default)]
1045    pub cd_total_ticks: u64,
1046}
1047
1048/// One combat target slot in the observer HUD.
1049#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1050pub struct CombatSlotHud {
1051    pub slot_index: u8,
1052    #[serde(default)]
1053    pub target_entity_id: Option<EntityId>,
1054    #[serde(default)]
1055    pub target_label: Option<String>,
1056    #[serde(default)]
1057    pub target: Option<CombatTargetHud>,
1058    #[serde(default)]
1059    pub preset_id: Option<String>,
1060    #[serde(default)]
1061    pub preset_label: Option<String>,
1062    #[serde(default)]
1063    pub rotation: Vec<String>,
1064    #[serde(default)]
1065    pub rotation_index: u32,
1066    #[serde(default)]
1067    pub next_ability_id: Option<String>,
1068    #[serde(default)]
1069    pub auto_enabled: bool,
1070}
1071
1072/// One worn piece contribution in the defense breakdown.
1073#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1074pub struct DefensePieceHud {
1075    pub slot: BodySlot,
1076    pub label: String,
1077    pub template_id: String,
1078    #[serde(default)]
1079    pub armor_physical: f32,
1080    #[serde(default)]
1081    pub resists: Vec<(String, f32)>,
1082}
1083
1084impl Default for DefensePieceHud {
1085    fn default() -> Self {
1086        Self {
1087            slot: BodySlot::Head,
1088            label: String::new(),
1089            template_id: String::new(),
1090            armor_physical: 0.0,
1091            resists: Vec::new(),
1092        }
1093    }
1094}
1095
1096/// Aggregated mitigation / resist summary for the local player Equip UI.
1097#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1098pub struct DefenseHud {
1099    pub armor_physical: f32,
1100    pub vitality_contribution: f32,
1101    pub total_mitigation_rating: f32,
1102    /// `rating / (rating + K)` physical damage reduction fraction.
1103    pub estimated_physical_dr: f32,
1104    #[serde(default)]
1105    pub resists: Vec<(String, f32)>,
1106    #[serde(default)]
1107    pub pieces: Vec<DefensePieceHud>,
1108}
1109
1110/// Observer combat HUD (local player only).
1111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1112pub struct CombatHud {
1113    pub in_combat: bool,
1114    /// T1 auto rotation (legacy field mirrors slot 1).
1115    pub auto_attack: bool,
1116    pub has_los: bool,
1117    pub attack_cd_ticks: u64,
1118    #[serde(default)]
1119    pub ability_id: String,
1120    #[serde(default)]
1121    pub target_entity_id: Option<EntityId>,
1122    #[serde(default)]
1123    pub target_label: Option<String>,
1124    #[serde(default)]
1125    pub max_target_slots: u8,
1126    #[serde(default)]
1127    pub slots: Vec<CombatSlotHud>,
1128    #[serde(default)]
1129    pub rotation_presets: Vec<RotationPreset>,
1130    #[serde(default)]
1131    pub gcd_ticks: u64,
1132    #[serde(default)]
1133    pub mainhand_template_id: Option<String>,
1134    #[serde(default)]
1135    pub mainhand_label: Option<String>,
1136    #[serde(default)]
1137    pub offhand_template_id: Option<String>,
1138    #[serde(default)]
1139    pub offhand_label: Option<String>,
1140    /// Mainhand occupies this many hand sockets (`1` or `2`).
1141    #[serde(default)]
1142    pub mainhand_hand_slots: u8,
1143    /// Equipped body-slot items (backpack, belt, armor, jewelry).
1144    #[serde(default)]
1145    pub worn: Vec<(BodySlot, ItemStack)>,
1146    /// Live mitigation / resist summary for the Equip paperdoll.
1147    #[serde(default)]
1148    pub defense: Option<DefenseHud>,
1149    #[serde(default)]
1150    pub carry_mass: f32,
1151    #[serde(default)]
1152    pub carry_mass_max: f32,
1153    #[serde(default)]
1154    pub encumbrance: EncumbranceState,
1155    /// Keys on the virtual keychain (stowed, zero carry mass).
1156    #[serde(default)]
1157    pub keychain: Vec<ItemStack>,
1158    #[serde(default)]
1159    pub target: Option<CombatTargetHud>,
1160    #[serde(default)]
1161    pub cast: Option<CastProgressHud>,
1162    #[serde(default)]
1163    pub ability_cooldowns: Vec<AbilityCooldownHud>,
1164    #[serde(default)]
1165    pub blocking_active: bool,
1166    /// Live XP pools for the local player (updated every tick with combat HUD).
1167    #[serde(default)]
1168    pub progression_xp: Option<ProgressionXp>,
1169    #[serde(default)]
1170    pub progression_baseline: u16,
1171    #[serde(default)]
1172    pub progression_xp_base: f64,
1173    #[serde(default)]
1174    pub progression_xp_growth: f64,
1175    #[serde(default)]
1176    pub attributes: Option<PrimaryAttributes>,
1177    #[serde(default)]
1178    pub skills: Option<PlayerSkills>,
1179}
1180
1181/// Hired worker automation mode on the wire.
1182#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1183#[serde(rename_all = "snake_case")]
1184pub enum WorkerModeView {
1185    Companion,
1186    JobLoop,
1187    /// Deliberately parked — no route, no follow. The worker stands down
1188    /// (still draws wages) until the employer assigns a mode/route again.
1189    Idle,
1190}
1191
1192/// Hired worker FSM state on the wire.
1193#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1194#[serde(rename_all = "snake_case")]
1195pub enum WorkerStateView {
1196    Idle,
1197    Traveling,
1198    Working,
1199    Resting,
1200    Waiting,
1201    Strike,
1202    Dismissed,
1203}
1204
1205/// Compact vitals for hired worker HUD rows.
1206#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
1207pub struct WorkerVitalsSummary {
1208    pub health_pct: f32,
1209    pub stamina_pct: f32,
1210}
1211
1212/// High-level route shape — `harvest_loop` (legacy flat lists) or `ordered` (typed stop list).
1213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1214#[serde(rename_all = "snake_case")]
1215pub enum WorkerRouteKindView {
1216    #[default]
1217    HarvestLoop,
1218    Ordered,
1219}
1220
1221/// Saved worker route for UI / route editor reload.
1222#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1223pub struct WorkerRouteView {
1224    #[serde(default)]
1225    pub kind: WorkerRouteKindView,
1226    #[serde(default)]
1227    pub lodging_container_id: Option<String>,
1228    /// Legacy `harvest_loop` waypoint list.
1229    #[serde(default)]
1230    pub outbound_waypoints: Vec<WorkerRouteWaypointView>,
1231    /// Legacy `harvest_loop` node id list.
1232    #[serde(default)]
1233    pub harvest_nodes: Vec<String>,
1234    #[serde(default = "default_route_carry_ratio")]
1235    pub carry_return_ratio: f32,
1236    /// Ordered-route typed stops (`kind: ordered`).
1237    #[serde(default)]
1238    pub stops: Vec<WorkerRouteStopView>,
1239}
1240
1241fn default_route_carry_ratio() -> f32 {
1242    0.90
1243}
1244
1245fn default_true_view() -> bool {
1246    true
1247}
1248
1249/// One withdraw line for a `WithdrawFrom` route stop view.
1250#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1251pub struct WorkerWithdrawItemView {
1252    pub template: String,
1253    #[serde(default)]
1254    pub qty: u32,
1255    /// Take every stack of this template (carry-capped). Wins over `qty`.
1256    #[serde(default)]
1257    pub all: bool,
1258}
1259
1260#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1261pub struct WorkerRouteWaypointView {
1262    pub x: f32,
1263    pub y: f32,
1264    pub z: f32,
1265}
1266
1267/// One typed stop in an ordered worker route.
1268///
1269/// NOTE: this is the wire (postcard) view. Postcard does **not** support
1270/// internally-tagged enums (`#[serde(tag = ...)]` — it returns `WontImplement`
1271/// from `deserialize_any`), so this enum uses serde's default **external tagging**.
1272/// The sim-side `WorkerRouteStop` (`crates/sim/src/worker_job.rs`) is the YAML-facing
1273/// twin and keeps its `tag = "stop"` for human-authored job YAML; the two never share
1274/// a wire format.
1275#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1276#[serde(rename_all = "snake_case")]
1277pub enum WorkerRouteStopView {
1278    Waypoint {
1279        x: f32,
1280        y: f32,
1281        #[serde(default)]
1282        z: f32,
1283    },
1284    HarvestNode {
1285        node_id: String,
1286    },
1287    DepositAt {
1288        container_id: String,
1289        #[serde(default)]
1290        filter: Option<Vec<String>>,
1291    },
1292    TradeWith {
1293        #[serde(default)]
1294        npc_id: Option<String>,
1295        template: String,
1296        #[serde(default = "default_true_view")]
1297        sell_all: bool,
1298    },
1299    WithdrawFrom {
1300        container_id: String,
1301        items: Vec<WorkerWithdrawItemView>,
1302    },
1303    CraftAt {
1304        device: String,
1305        blueprint: String,
1306        #[serde(default)]
1307        qty: Option<u32>,
1308    },
1309    RestIfNeeded,
1310    Wait {
1311        #[serde(default)]
1312        wait_ticks: u64,
1313    },
1314}
1315
1316/// Copper ledger category (expense negative / income positive on the wire entry).
1317#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1318#[serde(rename_all = "snake_case")]
1319pub enum LedgerCategory {
1320    Workers,
1321    Hire,
1322    Train,
1323    ShopBuy,
1324    Taxes,
1325    WorkerSales,
1326    TraderSales,
1327    Other,
1328}
1329
1330impl LedgerCategory {
1331    pub fn as_str(self) -> &'static str {
1332        match self {
1333            Self::Workers => "workers",
1334            Self::Hire => "hire",
1335            Self::Train => "train",
1336            Self::ShopBuy => "shop_buy",
1337            Self::Taxes => "taxes",
1338            Self::WorkerSales => "worker_sales",
1339            Self::TraderSales => "trader_sales",
1340            Self::Other => "other",
1341        }
1342    }
1343}
1344
1345#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1346pub struct LedgerEntryView {
1347    pub id: uuid::Uuid,
1348    pub game_day: u64,
1349    pub signed_copper: i64,
1350    pub category: LedgerCategory,
1351    #[serde(default)]
1352    pub label: String,
1353}
1354
1355#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1356pub struct LedgerPeriodTotals {
1357    /// Absolute copper spent by category key.
1358    #[serde(default)]
1359    pub expenses: std::collections::HashMap<String, u64>,
1360    /// Absolute copper earned by category key.
1361    #[serde(default)]
1362    pub income: std::collections::HashMap<String, u64>,
1363    pub expense_copper: u64,
1364    pub income_copper: u64,
1365    /// income − expenses (may be negative).
1366    pub cash_flow_copper: i64,
1367}
1368
1369#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1370pub struct PlayerLedgerView {
1371    pub current_game_day: u64,
1372    #[serde(default)]
1373    pub period_day: LedgerPeriodTotals,
1374    #[serde(default)]
1375    pub period_week: LedgerPeriodTotals,
1376    #[serde(default)]
1377    pub period_month: LedgerPeriodTotals,
1378    #[serde(default)]
1379    pub period_lifetime: LedgerPeriodTotals,
1380    #[serde(default)]
1381    pub recent: Vec<LedgerEntryView>,
1382    /// Copper on the character (inventory + worn bags).
1383    #[serde(default)]
1384    pub wealth_on_person_copper: u64,
1385    /// Copper in owned placed storage (chests, lodging, banks, etc.).
1386    #[serde(default)]
1387    pub wealth_in_storage_copper: u64,
1388    /// `wealth_on_person_copper + wealth_in_storage_copper`.
1389    #[serde(default)]
1390    pub wealth_total_copper: u64,
1391    /// Live payroll burn (cp per wage interval) for hired workers.
1392    #[serde(default)]
1393    pub live_expense_per_interval_copper: u64,
1394    /// Estimated copper from one full worker job loop (broker sell steps).
1395    #[serde(default)]
1396    pub live_income_route_est_per_loop_copper: u64,
1397    /// Recent average income (worker/trader sales) per wage interval.
1398    #[serde(default)]
1399    pub live_income_avg_per_interval_copper: u64,
1400    /// Number of wage intervals in the rolling average window.
1401    #[serde(default)]
1402    pub live_income_avg_window_intervals: u32,
1403    /// `live_income_avg_per_interval_copper − live_expense_per_interval_copper`.
1404    #[serde(default)]
1405    pub live_net_avg_per_interval_copper: i64,
1406}
1407
1408/// Gameplay analytics metric keys (extensible string on the wire via rename).
1409#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1410#[serde(rename_all = "snake_case")]
1411pub enum AnalyticsMetric {
1412    NpcKill,
1413    WildlifeKill,
1414    Harvest,
1415    QuestComplete,
1416    QuestAccept,
1417    QuestAbandon,
1418    PlayerDeath,
1419    Craft,
1420    WorkerHire,
1421    WorkerDismiss,
1422    WorkerTeach,
1423    NpcTalk,
1424    ShopBuy,
1425    ShopSell,
1426    PlaceContainer,
1427    PickupContainer,
1428    PickupDrop,
1429    ConsumableUse,
1430    AbilityUse,
1431    DistanceWalkedM,
1432    DoorUse,
1433    BuildingEnter,
1434}
1435
1436impl AnalyticsMetric {
1437    pub fn as_str(self) -> &'static str {
1438        match self {
1439            Self::NpcKill => "npc_kill",
1440            Self::WildlifeKill => "wildlife_kill",
1441            Self::Harvest => "harvest",
1442            Self::QuestComplete => "quest_complete",
1443            Self::QuestAccept => "quest_accept",
1444            Self::QuestAbandon => "quest_abandon",
1445            Self::PlayerDeath => "player_death",
1446            Self::Craft => "craft",
1447            Self::WorkerHire => "worker_hire",
1448            Self::WorkerDismiss => "worker_dismiss",
1449            Self::WorkerTeach => "worker_teach",
1450            Self::NpcTalk => "npc_talk",
1451            Self::ShopBuy => "shop_buy",
1452            Self::ShopSell => "shop_sell",
1453            Self::PlaceContainer => "place_container",
1454            Self::PickupContainer => "pickup_container",
1455            Self::PickupDrop => "pickup_drop",
1456            Self::ConsumableUse => "consumable_use",
1457            Self::AbilityUse => "ability_use",
1458            Self::DistanceWalkedM => "distance_walked_m",
1459            Self::DoorUse => "door_use",
1460            Self::BuildingEnter => "building_enter",
1461        }
1462    }
1463
1464    pub fn from_str_key(s: &str) -> Option<Self> {
1465        Some(match s {
1466            "npc_kill" => Self::NpcKill,
1467            "wildlife_kill" => Self::WildlifeKill,
1468            "harvest" => Self::Harvest,
1469            "quest_complete" => Self::QuestComplete,
1470            "quest_accept" => Self::QuestAccept,
1471            "quest_abandon" => Self::QuestAbandon,
1472            "player_death" => Self::PlayerDeath,
1473            "craft" => Self::Craft,
1474            "worker_hire" => Self::WorkerHire,
1475            "worker_dismiss" => Self::WorkerDismiss,
1476            "worker_teach" => Self::WorkerTeach,
1477            "npc_talk" => Self::NpcTalk,
1478            "shop_buy" => Self::ShopBuy,
1479            "shop_sell" => Self::ShopSell,
1480            "place_container" => Self::PlaceContainer,
1481            "pickup_container" => Self::PickupContainer,
1482            "pickup_drop" => Self::PickupDrop,
1483            "consumable_use" => Self::ConsumableUse,
1484            "ability_use" => Self::AbilityUse,
1485            "distance_walked_m" => Self::DistanceWalkedM,
1486            "door_use" => Self::DoorUse,
1487            "building_enter" => Self::BuildingEnter,
1488            _ => return None,
1489        })
1490    }
1491}
1492
1493#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1494pub struct CareerMetricRow {
1495    pub subject_id: String,
1496    pub amount: u64,
1497}
1498
1499/// Personal analytics summary for the Character `i` Career tab.
1500#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1501pub struct PlayerCareerView {
1502    pub current_game_day: u64,
1503    #[serde(default)]
1504    pub kills: Vec<CareerMetricRow>,
1505    #[serde(default)]
1506    pub harvests: Vec<CareerMetricRow>,
1507    pub quests_completed: u64,
1508    #[serde(default)]
1509    pub crafts: Vec<CareerMetricRow>,
1510    pub deaths: u64,
1511    pub npc_talks: u64,
1512    pub shop_buys: u64,
1513    pub shop_sells: u64,
1514    pub distance_m: u64,
1515    #[serde(default)]
1516    pub other: Vec<CareerMetricRow>,
1517}
1518
1519/// One player-hired worker visible in snapshot / tick deltas.
1520#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1521pub struct HiredWorkerView {
1522    pub instance_id: String,
1523    pub entity_id: EntityId,
1524    pub def_id: String,
1525    /// Display label (custom name when set, otherwise the NPC def label).
1526    pub label: String,
1527    pub x: f32,
1528    pub y: f32,
1529    pub z: f32,
1530    pub mode: WorkerModeView,
1531    pub state: WorkerStateView,
1532    #[serde(default)]
1533    pub step_label: String,
1534    pub vitals: WorkerVitalsSummary,
1535    #[serde(default)]
1536    pub carry_pct: f32,
1537    #[serde(default)]
1538    pub last_error: Option<String>,
1539    pub wage_copper_per_interval: u32,
1540    /// Estimated wage this interval (base + loop effort, travel meters excluded).
1541    #[serde(default)]
1542    pub effective_wage_copper: u32,
1543    /// Meters walked toward the next wage debit.
1544    #[serde(default)]
1545    pub wage_meters_walked: f32,
1546    /// Placed camp bed / lodging container this worker uses for deposit and rest.
1547    #[serde(default)]
1548    pub lodging_container_id: Option<String>,
1549    /// High-level harvest route (when job_loop route is configured).
1550    #[serde(default)]
1551    pub route: Option<WorkerRouteView>,
1552    /// Recipes this worker already knows (from hire `teaches` + employer teach).
1553    #[serde(default)]
1554    pub known_blueprint_ids: Vec<String>,
1555    /// Overall worker level (1+).
1556    #[serde(default = "default_worker_view_level")]
1557    pub level: u32,
1558    /// Cumulative worker XP.
1559    #[serde(default)]
1560    pub worker_xp: f64,
1561    /// Items the worker currently carries (employer-visible for give/take).
1562    #[serde(default)]
1563    pub inventory: Vec<ItemStack>,
1564}
1565
1566fn default_worker_view_level() -> u32 {
1567    1
1568}
1569
1570/// Server → client AOI-filtered entity updates for one sim tick.
1571#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1572pub struct TickDelta {
1573    pub tick: Tick,
1574    pub entities: Vec<EntityState>,
1575    #[serde(default)]
1576    pub resource_nodes: Vec<ResourceNodeView>,
1577    #[serde(default)]
1578    pub buildings: Vec<BuildingView>,
1579    #[serde(default)]
1580    pub doors: Vec<DoorView>,
1581    #[serde(default)]
1582    pub npcs: Vec<NpcView>,
1583    /// Observer inventory stacks (template → qty).
1584    #[serde(default)]
1585    pub inventory: Vec<ItemStack>,
1586    #[serde(default)]
1587    pub blueprints: Vec<BlueprintView>,
1588    #[serde(default)]
1589    pub world_clock: WorldClock,
1590    #[serde(default)]
1591    pub ground_drops: Vec<GroundDropView>,
1592    #[serde(default)]
1593    pub placed_containers: Vec<PlacedContainerView>,
1594    #[serde(default)]
1595    pub combat: Option<CombatHud>,
1596    #[serde(default)]
1597    pub interior_map: Option<InteriorMapView>,
1598    #[serde(default)]
1599    pub quest_log: Vec<QuestLogEntry>,
1600    #[serde(default)]
1601    pub hired_workers: Vec<HiredWorkerView>,
1602    #[serde(default)]
1603    pub interactables: Vec<InteractableView>,
1604    #[serde(default)]
1605    pub ledger: Option<PlayerLedgerView>,
1606    #[serde(default)]
1607    pub career: Option<PlayerCareerView>,
1608}
1609#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1610pub struct GroundDropView {
1611    pub id: String,
1612    pub template_id: String,
1613    pub quantity: u32,
1614    pub x: f32,
1615    pub y: f32,
1616    pub z: f32,
1617    /// Optional gfx tile id from the item template.
1618    #[serde(default)]
1619    pub tile_id: Option<String>,
1620}
1621
1622/// Full state on region enter or reconnect.
1623#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1624pub struct Snapshot {
1625    pub tick: Tick,
1626    pub chunk_rev: u64,
1627    /// Bumped when blueprints, catalog, segment, or settings reload.
1628    #[serde(default)]
1629    pub content_rev: u64,
1630    /// Stable publish revision from `assets/.content-publish.json` (for client asset sync).
1631    #[serde(default)]
1632    pub publish_rev: u64,
1633    pub entities: Vec<EntityState>,
1634    #[serde(default)]
1635    pub resource_nodes: Vec<ResourceNodeView>,
1636    /// Segment play area width in meters (for HUD).
1637    #[serde(default)]
1638    pub world_width_m: f32,
1639    #[serde(default)]
1640    pub world_height_m: f32,
1641    #[serde(default)]
1642    pub buildings: Vec<BuildingView>,
1643    #[serde(default)]
1644    pub doors: Vec<DoorView>,
1645    #[serde(default)]
1646    pub npcs: Vec<NpcView>,
1647    #[serde(default)]
1648    pub inventory: Vec<ItemStack>,
1649    #[serde(default)]
1650    pub blueprints: Vec<BlueprintView>,
1651    #[serde(default)]
1652    pub world_clock: WorldClock,
1653    #[serde(default)]
1654    pub terrain_zones: Vec<TerrainZoneView>,
1655    #[serde(default)]
1656    pub z_platforms: Vec<ZPlatformView>,
1657    #[serde(default)]
1658    pub z_transitions: Vec<ZTransitionView>,
1659    #[serde(default)]
1660    pub ground_drops: Vec<GroundDropView>,
1661    #[serde(default)]
1662    pub placed_containers: Vec<PlacedContainerView>,
1663    #[serde(default)]
1664    pub combat: Option<CombatHud>,
1665    #[serde(default)]
1666    pub interior_map: Option<InteriorMapView>,
1667    #[serde(default)]
1668    pub quest_log: Vec<QuestLogEntry>,
1669    #[serde(default)]
1670    pub hired_workers: Vec<HiredWorkerView>,
1671    #[serde(default)]
1672    pub interactables: Vec<InteractableView>,
1673    #[serde(default)]
1674    pub ledger: Option<PlayerLedgerView>,
1675    #[serde(default)]
1676    pub career: Option<PlayerCareerView>,
1677}
1678
1679/// Harvestable node visible to clients.
1680#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1681pub struct ResourceNodeView {
1682    pub id: String,
1683    pub label: String,
1684    pub x: f32,
1685    pub y: f32,
1686    pub z: f32,
1687    pub item_template: String,
1688    #[serde(default = "default_node_state")]
1689    pub state: ResourceNodeState,
1690    /// When true, players cannot walk through this node while available/harvesting.
1691    #[serde(default = "default_blocking_view")]
1692    pub blocking: bool,
1693    /// Collision radius for pathfinding (meters).
1694    #[serde(default = "default_blocking_radius_view")]
1695    pub blocking_radius_m: f32,
1696    /// Optional gfx tile id (`resource.oak_log`, …).
1697    #[serde(default)]
1698    pub tile_id: Option<String>,
1699    /// Resolved gfx sprite mode for `tile_id` (server-computed).
1700    #[serde(default)]
1701    pub sprite_mode: Option<String>,
1702    /// Canonical presentation key (`available`, `harvesting`, `depleted`).
1703    #[serde(default)]
1704    pub presentation_state: Option<String>,
1705}
1706
1707fn default_blocking_radius_view() -> f32 {
1708    0.8
1709}
1710
1711fn default_blocking_view() -> bool {
1712    true
1713}
1714
1715#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1716#[serde(rename_all = "snake_case")]
1717pub enum ResourceNodeState {
1718    Available,
1719    Harvesting,
1720    Cooldown,
1721}
1722
1723fn default_node_state() -> ResourceNodeState {
1724    ResourceNodeState::Available
1725}
1726
1727#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1728pub struct ItemStack {
1729    pub template_id: String,
1730    pub quantity: u32,
1731    /// Stable instance id — preserved across checkpoint/sync when set.
1732    #[serde(default)]
1733    pub item_instance_id: Option<Uuid>,
1734    /// Per-instance metadata (stat rolls, soul-bind, lock ids, etc.).
1735    #[serde(default)]
1736    pub props: BTreeMap<String, String>,
1737    /// Nested contents when this stack is a container instance.
1738    #[serde(default)]
1739    pub contents: Vec<ItemStack>,
1740    /// From item catalog when sent on wire (display only).
1741    #[serde(default)]
1742    pub display_name: Option<String>,
1743    /// From item catalog when sent on wire (`weapon`, `consumable`, …).
1744    #[serde(default)]
1745    pub category: Option<String>,
1746    /// Per-unit mass in kg from catalog (display / move UX).
1747    #[serde(default)]
1748    pub base_mass: Option<f32>,
1749    /// Per-unit volume from catalog (display / move UX).
1750    #[serde(default)]
1751    pub base_volume: Option<f32>,
1752    /// Container capacity when this stack is a container template.
1753    #[serde(default)]
1754    pub capacity_volume: Option<f32>,
1755    /// Whether the template stacks in inventory (from catalog).
1756    #[serde(default)]
1757    pub stackable: Option<bool>,
1758    /// Can be placed on the ground from inventory (from catalog).
1759    #[serde(default)]
1760    pub world_placeable: Option<bool>,
1761    /// Hired-worker lodging capacity when this template is placed (from catalog).
1762    #[serde(default)]
1763    pub worker_lodging_capacity: Option<u32>,
1764    /// Body slot this template equips into (from catalog; display / Equip UI).
1765    #[serde(default)]
1766    pub equip_slot: Option<BodySlot>,
1767    /// Template baseline armor rating (from catalog).
1768    #[serde(default)]
1769    pub armor_physical: Option<f32>,
1770    /// Template resists damage_type → value (from catalog).
1771    #[serde(default)]
1772    pub resists: Vec<(String, f32)>,
1773    /// Weapon hand occupancy when category is weapon (`1` or `2`).
1774    #[serde(default)]
1775    pub hand_slots: Option<u8>,
1776}
1777
1778impl ItemStack {
1779    pub fn simple(template_id: impl Into<String>, quantity: u32) -> Self {
1780        Self {
1781            template_id: template_id.into(),
1782            quantity,
1783            ..Default::default()
1784        }
1785    }
1786}
1787
1788impl Default for ItemStack {
1789    fn default() -> Self {
1790        Self {
1791            template_id: String::new(),
1792            quantity: 0,
1793            item_instance_id: None,
1794            props: BTreeMap::new(),
1795            contents: Vec::new(),
1796            display_name: None,
1797            category: None,
1798            base_mass: None,
1799            base_volume: None,
1800            capacity_volume: None,
1801            stackable: None,
1802            world_placeable: None,
1803            worker_lodging_capacity: None,
1804            equip_slot: None,
1805            armor_physical: None,
1806            resists: Vec::new(),
1807            hand_slots: None,
1808        }
1809    }
1810}
1811
1812/// Carry encumbrance band (`plans/08` §3.2).
1813#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1814#[serde(rename_all = "snake_case")]
1815pub enum EncumbranceState {
1816    #[default]
1817    Light,
1818    Heavy,
1819    Over,
1820}
1821
1822/// Body region a wearable item occupies — at most one item equipped per slot.
1823/// Armor, cloak, jewelry, and carriers (`Back` backpack, `Waist` belt). Hands
1824/// (mainhand / offhand) are separate combat sockets — not `BodySlot`.
1825#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)]
1826#[serde(rename_all = "snake_case")]
1827pub enum BodySlot {
1828    Head,
1829    /// Chest / torso armor. Serde alias `body` for older YAML / saves.
1830    #[serde(alias = "body")]
1831    Chest,
1832    /// Gauntlets / bracers / sleeves. Serde alias `arms` for older YAML / saves.
1833    #[serde(alias = "arms")]
1834    Forearms,
1835    Legs,
1836    Feet,
1837    Cloak,
1838    Back,
1839    Waist,
1840    Earrings,
1841    Necklace,
1842    Eyeglasses,
1843    /// Explicit rename: plain `snake_case` would be `ring_left1`.
1844    #[serde(rename = "ring_left_1", alias = "ring_left1")]
1845    RingLeft1,
1846    #[serde(rename = "ring_left_2", alias = "ring_left2")]
1847    RingLeft2,
1848    #[serde(rename = "ring_right_1", alias = "ring_right1")]
1849    RingRight1,
1850    #[serde(rename = "ring_right_2", alias = "ring_right2")]
1851    RingRight2,
1852}
1853
1854impl BodySlot {
1855    /// All worn slots in paperdoll / catalog order.
1856    pub const ALL: [BodySlot; 15] = [
1857        BodySlot::Head,
1858        BodySlot::Chest,
1859        BodySlot::Forearms,
1860        BodySlot::Legs,
1861        BodySlot::Feet,
1862        BodySlot::Cloak,
1863        BodySlot::Back,
1864        BodySlot::Waist,
1865        BodySlot::Earrings,
1866        BodySlot::Necklace,
1867        BodySlot::Eyeglasses,
1868        BodySlot::RingLeft1,
1869        BodySlot::RingLeft2,
1870        BodySlot::RingRight1,
1871        BodySlot::RingRight2,
1872    ];
1873
1874    pub fn as_str(self) -> &'static str {
1875        match self {
1876            BodySlot::Head => "head",
1877            BodySlot::Chest => "chest",
1878            BodySlot::Forearms => "forearms",
1879            BodySlot::Legs => "legs",
1880            BodySlot::Feet => "feet",
1881            BodySlot::Cloak => "cloak",
1882            BodySlot::Back => "back",
1883            BodySlot::Waist => "waist",
1884            BodySlot::Earrings => "earrings",
1885            BodySlot::Necklace => "necklace",
1886            BodySlot::Eyeglasses => "eyeglasses",
1887            BodySlot::RingLeft1 => "ring_left_1",
1888            BodySlot::RingLeft2 => "ring_left_2",
1889            BodySlot::RingRight1 => "ring_right_1",
1890            BodySlot::RingRight2 => "ring_right_2",
1891        }
1892    }
1893}
1894
1895/// Where an item lives for `MoveItem` / open-container UX.
1896#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1897#[serde(rename_all = "snake_case")]
1898pub enum InventoryLocation {
1899    /// Loose on-person inventory (not inside a worn/placed container).
1900    Root,
1901    /// Inside a worn item (backpack contents, or a pouch clipped onto a worn belt).
1902    Worn { slot: BodySlot },
1903    /// Inside a world-placed container.
1904    Placed { container_id: String },
1905    /// Virtual key ring — only `container_key` items; zero carry mass; persists with combat profile.
1906    Keychain,
1907}
1908
1909/// AOI view of a placeable chest on the map.
1910#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1911pub struct PlacedContainerView {
1912    pub id: String,
1913    pub template_id: String,
1914    pub display_name: String,
1915    pub x: f32,
1916    pub y: f32,
1917    pub z: f32,
1918    pub locked: bool,
1919    /// Observer can open (unlocked, or holds matching key).
1920    #[serde(default)]
1921    pub accessible: bool,
1922    #[serde(default)]
1923    pub owner_character_id: Option<Uuid>,
1924    /// Nested contents when `accessible` (empty when locked without key).
1925    #[serde(default)]
1926    pub contents: Vec<ItemStack>,
1927    /// Lock id for matching `container_key` props (`opens_lock_id`).
1928    #[serde(default)]
1929    pub lock_id: Option<String>,
1930    /// Internal storage capacity (liters) from item catalog.
1931    #[serde(default)]
1932    pub capacity_volume: Option<f32>,
1933    /// Container instance id (for MoveItem parent targeting).
1934    #[serde(default)]
1935    pub item_instance_id: Option<Uuid>,
1936    /// Optional gfx tile id from the item template.
1937    #[serde(default)]
1938    pub tile_id: Option<String>,
1939    /// Hired-worker slots when this is placed lodging (`category: lodging`).
1940    #[serde(default)]
1941    pub worker_lodging_capacity: Option<u32>,
1942}
1943
1944#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1945pub struct BlueprintIngredientView {
1946    pub template_id: String,
1947    pub quantity: u32,
1948    /// Always serialize — `true` is not bool::default() so postcard keeps it; explicit for clarity.
1949    pub consumed: bool,
1950}
1951
1952#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1953pub struct ToolRequirementView {
1954    pub item: String,
1955    /// Always serialize — postcard omits `false` by default, which breaks roundtrip without explicit value.
1956    pub consumed: bool,
1957}
1958
1959#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1960pub struct SkillRequirementView {
1961    pub skill: String,
1962    pub level: u32,
1963}
1964
1965#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1966pub struct BlueprintView {
1967    pub id: String,
1968    pub label: String,
1969    pub output: String,
1970    pub output_qty: u32,
1971    pub craft_ticks: u32,
1972    pub inputs: Vec<BlueprintIngredientView>,
1973    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
1974    #[serde(default)]
1975    pub station: Option<String>,
1976    #[serde(default)]
1977    pub category: Option<String>,
1978    #[serde(default)]
1979    pub required_tools: Vec<ToolRequirementView>,
1980    #[serde(default)]
1981    pub skill: Option<SkillRequirementView>,
1982    #[serde(default)]
1983    pub failure_chance: f32,
1984    /// Copper cost for the employer to teach this recipe to a hired worker.
1985    #[serde(default)]
1986    pub worker_train_copper: u64,
1987}
1988
1989/// Terrain overlay from segment YAML (`terrain_zones`).
1990#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1991#[serde(rename_all = "snake_case")]
1992pub enum TerrainKindView {
1993    #[default]
1994    Grass,
1995    Hill,
1996    Bog,
1997    ShallowWater,
1998    DeepWater,
1999    Trail,
2000    Rock,
2001}
2002
2003#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2004pub struct TerrainZoneView {
2005    pub id: String,
2006    pub x0: f32,
2007    pub y0: f32,
2008    pub x1: f32,
2009    pub y1: f32,
2010    #[serde(default)]
2011    pub kind: TerrainKindView,
2012    /// Ground elevation at this zone (m).
2013    #[serde(default)]
2014    pub elevation: f32,
2015    /// Optional map glyph override (single character); falls back to terrain kind catalog.
2016    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
2017    #[serde(default)]
2018    pub glyph: Option<String>,
2019    /// Optional color (`#RRGGBB` or ratatui name); falls back to kind / elevation tint.
2020    #[serde(default)]
2021    pub color: Option<String>,
2022    /// Optional gfx tile id (`terrain.grass`, …) — presentation only.
2023    #[serde(default)]
2024    pub tile_id: Option<String>,
2025    /// Overlap priority — higher wins (`segment.terrain_zones`).
2026    #[serde(default)]
2027    pub z_order: i32,
2028}
2029
2030/// Walkable platform at a fixed z (`segment.z_platforms`).
2031#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2032pub struct ZPlatformView {
2033    pub id: String,
2034    pub z: f32,
2035    pub x0: f32,
2036    pub y0: f32,
2037    pub x1: f32,
2038    pub y1: f32,
2039}
2040
2041/// Stairs / ramp linking two z bands (`segment.z_transitions`).
2042#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2043pub struct ZTransitionView {
2044    pub id: String,
2045    pub z_from: f32,
2046    pub z_to: f32,
2047    pub x0: f32,
2048    pub y0: f32,
2049    pub x1: f32,
2050    pub y1: f32,
2051}
2052
2053#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2054pub struct BuildingView {
2055    pub id: String,
2056    pub label: String,
2057    pub x: f32,
2058    pub y: f32,
2059    pub width_m: f32,
2060    pub depth_m: f32,
2061    #[serde(default)]
2062    pub interior_blueprint: Option<String>,
2063    #[serde(default)]
2064    pub tags: Vec<String>,
2065}
2066
2067#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2068pub struct DoorView {
2069    pub id: String,
2070    pub building_id: String,
2071    pub x: f32,
2072    pub y: f32,
2073    #[serde(default)]
2074    pub open: bool,
2075    #[serde(default)]
2076    pub portal: Option<String>,
2077}
2078
2079#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2080pub struct InteriorRoomView {
2081    pub id: String,
2082    pub label: String,
2083    pub floor: i32,
2084    pub x0: f32,
2085    pub y0: f32,
2086    pub x1: f32,
2087    pub y1: f32,
2088    #[serde(default)]
2089    pub floor_color: Option<String>,
2090    #[serde(default)]
2091    pub floor_glyph: Option<String>,
2092}
2093
2094#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2095pub struct InteriorDoorView {
2096    pub id: String,
2097    pub room_a: String,
2098    pub room_b: String,
2099    pub x: f32,
2100    pub y: f32,
2101    pub kind: String,
2102}
2103
2104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2105pub struct InteriorMapView {
2106    pub building_id: String,
2107    pub blueprint_id: String,
2108    pub background_color: String,
2109    #[serde(default)]
2110    pub default_floor_color: Option<String>,
2111    #[serde(default = "default_floor_height_view")]
2112    pub floor_height_m: f32,
2113    pub rooms: Vec<InteriorRoomView>,
2114    #[serde(default)]
2115    pub room_doors: Vec<InteriorDoorView>,
2116}
2117
2118fn default_floor_height_view() -> f32 {
2119    3.0
2120}
2121
2122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2123pub struct NpcView {
2124    pub id: String,
2125    pub label: String,
2126    pub role: String,
2127    pub x: f32,
2128    pub y: f32,
2129    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
2130    #[serde(default)]
2131    pub building_id: Option<String>,
2132    /// Authoritative sim entity for wildlife / combat targets.
2133    #[serde(default)]
2134    pub entity_id: Option<EntityId>,
2135    #[serde(default)]
2136    pub life_state: Option<LifeState>,
2137    #[serde(default)]
2138    pub hp_pct: Option<f32>,
2139    /// True when this NPC has buy/sell/teach offers (`npc_has_market`).
2140    #[serde(default)]
2141    pub can_trade: bool,
2142    /// Gfx sprite sheet id (`assets/gfx/sprites/`). Client falls back to `npc.{id}` / `npc.{role}`.
2143    #[serde(default)]
2144    pub tile_id: Option<String>,
2145    /// Wildlife FSM state (`idle`, `chase`, `combat`, …) when behavior-driven (debug).
2146    #[serde(default)]
2147    pub behavior_state: Option<String>,
2148    /// Canonical gfx presentation key (`combat`, `pursue`, `walking`, `talking`, …).
2149    #[serde(default)]
2150    pub presentation_state: Option<String>,
2151    /// Resolved gfx sprite mode for `tile_id` (server-computed).
2152    #[serde(default)]
2153    pub sprite_mode: Option<String>,
2154}
2155
2156#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2157pub struct UseResult {
2158    pub template_id: String,
2159    pub hunger_restored: f32,
2160    pub thirst_restored: f32,
2161    #[serde(default)]
2162    pub health_restored: f32,
2163    #[serde(default)]
2164    pub mana_restored: f32,
2165    #[serde(default)]
2166    pub cleared_dot_ids: Vec<String>,
2167}
2168
2169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2170pub struct CraftResult {
2171    pub blueprint_id: String,
2172    pub outputs: Vec<ItemStack>,
2173    pub consumed: Vec<ItemStack>,
2174    /// 1-based index within the submitted batch (1 when not batching).
2175    #[serde(default = "default_one")]
2176    pub batch_index: u32,
2177    /// Total crafts requested in this batch (1 when not batching).
2178    #[serde(default = "default_one")]
2179    pub batch_total: u32,
2180}
2181
2182fn default_one() -> u32 {
2183    1
2184}
2185
2186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2187pub struct DeathNotice {
2188    pub entity_id: EntityId,
2189    pub respawn_x: f32,
2190    pub respawn_y: f32,
2191    pub message: String,
2192}
2193
2194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2195pub struct InteractionNotice {
2196    pub target_id: String,
2197    pub message: String,
2198    #[serde(default)]
2199    pub coins_delta: i32,
2200    #[serde(default)]
2201    pub inventory_delta: Vec<ItemStack>,
2202}
2203
2204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2205#[serde(rename_all = "snake_case")]
2206pub enum NpcTalkTrustFlag {
2207    Stranger,
2208    Acquainted,
2209    Trusted,
2210}
2211
2212#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
2213#[serde(rename_all = "snake_case")]
2214pub enum NpcTalkDepth {
2215    #[default]
2216    Full,
2217    Brief,
2218    Unavailable,
2219}
2220
2221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2222pub struct NpcTalkOpened {
2223    pub npc_id: String,
2224    pub npc_label: String,
2225    pub greeting: String,
2226    pub trust_flag: NpcTalkTrustFlag,
2227    #[serde(default)]
2228    pub talk_depth: NpcTalkDepth,
2229    #[serde(default = "default_true")]
2230    pub trade_allowed: bool,
2231}
2232
2233fn default_true() -> bool {
2234    true
2235}
2236
2237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2238pub struct NpcTalkPending {
2239    pub npc_id: String,
2240}
2241
2242#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2243pub struct NpcTalkReply {
2244    pub npc_id: String,
2245    pub line: String,
2246    pub trust_flag: NpcTalkTrustFlag,
2247    #[serde(default)]
2248    pub wind_down: bool,
2249    #[serde(default)]
2250    pub trade_disabled: bool,
2251}
2252
2253#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2254pub struct NpcTalkClosed {
2255    pub npc_id: String,
2256}
2257
2258#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2259pub struct NpcTalkError {
2260    pub npc_id: String,
2261    pub reason: String,
2262}
2263
2264#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2265#[serde(rename_all = "snake_case")]
2266pub enum QuestStatusView {
2267    Available,
2268    Active,
2269    Completed,
2270}
2271
2272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2273pub struct QuestObjectiveProgress {
2274    pub label: String,
2275    pub current: u32,
2276    pub required: u32,
2277    pub done: bool,
2278}
2279
2280#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2281pub struct QuestLogEntry {
2282    pub quest_id: String,
2283    pub title: String,
2284    pub description: String,
2285    pub status: QuestStatusView,
2286    #[serde(default)]
2287    pub current_step_id: Option<String>,
2288    #[serde(default)]
2289    pub current_step_title: String,
2290    #[serde(default)]
2291    pub objectives: Vec<QuestObjectiveProgress>,
2292    #[serde(default)]
2293    pub is_tracked: bool,
2294    #[serde(default)]
2295    pub can_withdraw: bool,
2296}
2297
2298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2299pub struct InteractableView {
2300    pub id: String,
2301    pub kind: String,
2302    pub label: String,
2303    pub x: f32,
2304    pub y: f32,
2305    pub z: f32,
2306    #[serde(default)]
2307    pub board_id: Option<String>,
2308}
2309
2310#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2311pub struct QuestOffer {
2312    pub quest_id: String,
2313    pub title: String,
2314    pub description: String,
2315    #[serde(default)]
2316    pub step_count: u32,
2317}
2318
2319#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2320pub struct QuestNotice {
2321    pub quest_id: String,
2322    pub title: String,
2323    pub message: String,
2324}
2325
2326#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2327#[serde(rename_all = "snake_case")]
2328pub enum ShopOfferKind {
2329    Item,
2330    Blueprint,
2331}
2332
2333#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2334pub struct ShopOffer {
2335    pub offer_id: String,
2336    pub kind: ShopOfferKind,
2337    pub label: String,
2338    #[serde(default)]
2339    pub template_id: Option<String>,
2340    #[serde(default)]
2341    pub blueprint_id: Option<String>,
2342    pub price_copper: u32,
2343    #[serde(default)]
2344    pub affordable: bool,
2345    #[serde(default)]
2346    pub already_owned: bool,
2347}
2348
2349#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2350pub struct ShopBuyLine {
2351    pub template_id: String,
2352    pub label: String,
2353    pub quantity: u32,
2354    pub price_copper: u32,
2355}
2356
2357#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2358pub struct ShopCatalog {
2359    pub npc_id: String,
2360    pub npc_label: String,
2361    #[serde(default)]
2362    pub sells: Vec<ShopOffer>,
2363    #[serde(default)]
2364    pub buys: Vec<ShopBuyLine>,
2365}
2366
2367#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2368pub struct HarvestResult {
2369    pub node_id: String,
2370    /// Stack quantity granted (not one-node-one-instance).
2371    pub quantity: u32,
2372    pub item_template: String,
2373    /// Optional DB row id when persisted to control plane.
2374    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
2375    #[serde(default)]
2376    pub item_instance_id: Option<Uuid>,
2377}
2378
2379/// Every on-wire payload is wrapped for versioning and codec uniformity.
2380#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2381pub struct Envelope<T> {
2382    pub protocol_version: u16,
2383    pub payload: T,
2384}
2385
2386impl<T> Envelope<T> {
2387    pub fn new(payload: T) -> Self {
2388        Self {
2389            protocol_version: crate::PROTOCOL_VERSION,
2390            payload,
2391        }
2392    }
2393}
2394
2395/// Session handshake after transport connect.
2396#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2397pub struct Hello {
2398    pub client_name: String,
2399    pub protocol_version: u16,
2400    #[serde(default)]
2401    pub auth: AuthCredential,
2402    /// Required for session auth; embedded in `ApiToken` variant otherwise.
2403    #[serde(default)]
2404    pub character_id: Option<Uuid>,
2405}
2406
2407/// How the client authenticates to the game gateway.
2408/// Uses default serde enum encoding (postcard-compatible; not internally tagged).
2409#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2410#[serde(rename_all = "snake_case")]
2411pub enum AuthCredential {
2412    DevLocal,
2413    Session { token: String },
2414    ApiToken { token: String, character_id: Uuid },
2415}
2416
2417impl Default for AuthCredential {
2418    fn default() -> Self {
2419        Self::DevLocal
2420    }
2421}
2422
2423#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2424pub struct Welcome {
2425    pub session_id: SessionId,
2426    pub entity_id: EntityId,
2427    pub snapshot: Snapshot,
2428}
2429
2430#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2431pub enum ServerMessage {
2432    Welcome(Welcome),
2433    /// Static world layers refreshed (blueprints, catalog, map, terrain) — no client restart.
2434    ContentUpdated(Snapshot),
2435    Tick(TickDelta),
2436    IntentAck {
2437        entity_id: EntityId,
2438        seq: Seq,
2439        tick: Tick,
2440    },
2441    Chat(ChatMessage),
2442    HarvestResult(HarvestResult),
2443    UseResult(UseResult),
2444    CraftResult(CraftResult),
2445    Death(DeathNotice),
2446    Interaction(InteractionNotice),
2447    ShopOpened(ShopCatalog),
2448    NpcTalkOpened(NpcTalkOpened),
2449    NpcTalkPending(NpcTalkPending),
2450    NpcTalkReply(NpcTalkReply),
2451    NpcTalkClosed(NpcTalkClosed),
2452    NpcTalkError(NpcTalkError),
2453    QuestOffer(QuestOffer),
2454    QuestAccepted(QuestNotice),
2455    QuestWithdrawn(QuestNotice),
2456    QuestStepCompleted(QuestNotice),
2457    QuestCompleted(QuestNotice),
2458}
2459
2460#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2461pub enum ClientMessage {
2462    Hello(Hello),
2463    Intent(Intent),
2464    Disconnect,
2465}
2466
2467#[cfg(test)]
2468mod tests {
2469    use super::*;
2470
2471    #[test]
2472    fn pristine_vitals_state_yields_full_pools() {
2473        let attrs = PrimaryAttributes::default();
2474        let vitals = StoredVitalsState::default().apply_to(attrs);
2475        assert!(vitals.health > 0.0);
2476        assert_eq!(vitals.health, vitals.health_max);
2477        assert!((vitals.mana_max - 61.0).abs() < 0.01);
2478    }
2479
2480    #[test]
2481    fn saved_vitals_scale_when_pool_max_increases() {
2482        let mut attrs = PrimaryAttributes::default();
2483        attrs.intelligence = 140;
2484        attrs.wisdom = 140;
2485        let saved = StoredVitalsState {
2486            health: 100.0,
2487            mana: 14.0,
2488            stamina: 100.0,
2489            ..StoredVitalsState::default()
2490        };
2491        let vitals = saved.apply_to(attrs);
2492        assert!(vitals.mana_max > 55.0);
2493        assert!(
2494            (vitals.mana - vitals.mana_max).abs() < 0.01,
2495            "full legacy mana bar migrates to full new bar"
2496        );
2497    }
2498
2499    #[test]
2500    fn empty_vitals_state_is_pristine() {
2501        let pristine = StoredVitalsState {
2502            health: 0.0,
2503            mana: 0.0,
2504            stamina: 0.0,
2505            hunger: 0.0,
2506            thirst: 0.0,
2507            coins: 0,
2508            deaths: 0,
2509            life_state: LifeState::Alive,
2510        };
2511        assert!(pristine.is_pristine());
2512        let vitals = pristine.apply_to(PrimaryAttributes::default());
2513        assert!(vitals.health > 0.0);
2514    }
2515
2516    #[test]
2517    fn stored_vitals_roundtrip_preserves_partial_pools() {
2518        let attrs = PrimaryAttributes::default();
2519        let mut live = PlayerVitals::from_attributes(attrs);
2520        live.health = 25.0;
2521        live.hunger = 77.0;
2522        live.deaths = 2;
2523        let stored = StoredVitalsState::from_live(&live);
2524        let restored = stored.apply_to(attrs);
2525        assert!(
2526            (restored.health - 25.0).abs() < 0.01,
2527            "partial HP below cap stays absolute"
2528        );
2529        assert_eq!(restored.hunger, 77.0);
2530        assert_eq!(restored.deaths, 2);
2531    }
2532
2533    #[test]
2534    fn skill_tiers_start_at_zero() {
2535        let skill = SkillProgress::default();
2536        assert_eq!(skill.level, 0);
2537        assert_eq!(skill.display_tier(), 0);
2538        let trained = SkillProgress {
2539            level: 250,
2540            last_trained_tick: 1,
2541        };
2542        assert_eq!(trained.display_tier(), 2);
2543    }
2544
2545    #[test]
2546    fn quest_server_messages_roundtrip_json() {
2547        use crate::codec::{Codec, PostcardCodec};
2548
2549        let offer = ServerMessage::QuestOffer(QuestOffer {
2550            quest_id: "ada_goblin_hunt".into(),
2551            title: "Goblin Trouble".into(),
2552            description: "Help Ada".into(),
2553            step_count: 3,
2554        });
2555        let notice = ServerMessage::QuestAccepted(QuestNotice {
2556            quest_id: "ada_goblin_hunt".into(),
2557            title: "Goblin Trouble".into(),
2558            message: "Quest accepted".into(),
2559        });
2560        for msg in [offer, notice] {
2561            let bytes = PostcardCodec.encode(&msg).unwrap();
2562            let decoded: ServerMessage = PostcardCodec.decode(&bytes).unwrap();
2563            assert_eq!(decoded, msg);
2564        }
2565    }
2566}