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/// Fallback display text for snake_case ids when no authored label exists.
462///
463/// Example: `heal_touch` → `Heal Touch`. Prefer catalog/`label` fields when present.
464pub fn humanize_snake_id(id: &str) -> String {
465    id.split('_')
466        .filter(|part| !part.is_empty())
467        .map(|part| {
468            let mut chars = part.chars();
469            match chars.next() {
470                None => String::new(),
471                Some(first) => first.to_uppercase().chain(chars).collect(),
472            }
473        })
474        .collect::<Vec<_>>()
475        .join(" ")
476}
477
478/// Character-owned combat ability (skill book / temporary grant).
479#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
480pub struct KnownAbility {
481    pub ability_id: String,
482    /// When false, `expires_at_tick` must be set.
483    #[serde(default = "default_known_permanent")]
484    pub permanent: bool,
485    /// Sim tick when a temporary grant ends (`None` = permanent).
486    #[serde(default)]
487    pub expires_at_tick: Option<u64>,
488}
489
490fn default_known_permanent() -> bool {
491    true
492}
493
494impl KnownAbility {
495    pub fn permanent(ability_id: impl Into<String>) -> Self {
496        Self {
497            ability_id: ability_id.into(),
498            permanent: true,
499            expires_at_tick: None,
500        }
501    }
502
503    pub fn is_active(&self, tick: u64) -> bool {
504        if self.permanent {
505            return true;
506        }
507        match self.expires_at_tick {
508            Some(exp) => tick < exp,
509            None => false,
510        }
511    }
512}
513
514/// Saved ability rotation preset (`plans/26` §C3).
515#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
516pub struct RotationPreset {
517    pub id: String,
518    pub label: String,
519    #[serde(default)]
520    pub abilities: Vec<String>,
521}
522
523impl RotationPreset {
524    pub fn melee_default(ability_id: impl Into<String>) -> Self {
525        let id = ability_id.into();
526        Self {
527            id: "melee".into(),
528            // Built-in weapon auto preset — ability tracks equipped mainhand.
529            label: "Weapon".into(),
530            abilities: vec![id],
531        }
532    }
533
534    pub fn is_weapon_preset(&self) -> bool {
535        self.id == "melee"
536    }
537}
538
539/// Durable combat state (`plans/12` §4.1) — target by wildlife instance id, not entity id.
540#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
541pub struct StoredTargetSlot {
542    pub instance_id: Option<String>,
543    #[serde(default)]
544    pub preset_id: Option<String>,
545    #[serde(default)]
546    pub rotation_index: u32,
547    #[serde(default)]
548    pub auto_enabled: bool,
549}
550
551#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
552#[serde(default)]
553pub struct StoredCombatProfile {
554    /// Wildlife `instance_id` (e.g. `meadow-rabbits-0`), stable across worker restarts.
555    pub combat_target_instance_id: Option<String>,
556    pub in_combat: bool,
557    pub last_combat_tick: u64,
558    pub last_attack_tick: u64,
559    pub cooldowns_until_tick: BTreeMap<String, u64>,
560    #[serde(default = "default_auto_attack")]
561    pub auto_attack_enabled: bool,
562    /// Equipped mainhand weapon template id (`plans/26` §C2b).
563    #[serde(default)]
564    pub mainhand_template_id: Option<String>,
565    /// Specific mainhand instance (bindings / unique gear).
566    #[serde(default)]
567    pub mainhand_instance_id: Option<Uuid>,
568    /// Equipped offhand (shield / dual-wield). Cleared when mainhand is two-handed.
569    #[serde(default)]
570    pub offhand_template_id: Option<String>,
571    /// Specific offhand instance.
572    #[serde(default)]
573    pub offhand_instance_id: Option<Uuid>,
574    /// Equipped body-slot items (backpack, belt, armor, jewelry), nested contents included
575    /// (e.g. pouches clipped onto a worn belt). At most one entry per `BodySlot`.
576    #[serde(default)]
577    pub worn: Vec<(BodySlot, ItemStack)>,
578    /// Player-authored rotation library (`plans/26` §C3).
579    #[serde(default)]
580    pub rotation_presets: Vec<RotationPreset>,
581    /// Per-target slot state (T1/T2).
582    #[serde(default)]
583    pub target_slots: Vec<StoredTargetSlot>,
584    /// Discovered craft recipe ids (`plans/09` §C2 hybrid discovery).
585    #[serde(default)]
586    pub known_blueprint_ids: Vec<String>,
587    /// Keys stowed on the keychain (not counted toward carry mass).
588    #[serde(default)]
589    pub keychain: Vec<ItemStack>,
590    /// Paired whisper stones ("phones") — zero carry mass; persists with combat profile.
591    #[serde(default)]
592    pub whisper_pouch: Vec<ItemStack>,
593    /// Learned combat abilities (skill books / temporary grants). Empty on load → default unarmed.
594    #[serde(default)]
595    pub known_abilities: Vec<KnownAbility>,
596    /// Hotbar slots 1–9 (`None` / missing = unbound). Empty on load → slot 1 = unarmed.
597    #[serde(default)]
598    pub hotbar: Vec<Option<String>>,
599    /// Bump when combat ability progression model changes. `0` = pre–skill-book open kit.
600    #[serde(default)]
601    pub abilities_schema_version: u32,
602    /// Secure bank ledger balance in copper (`plans/08` §8). Persists with combat profile.
603    #[serde(default)]
604    pub bank_balance_copper: u64,
605}
606
607fn default_auto_attack() -> bool {
608    true
609}
610
611impl Default for StoredCombatProfile {
612    fn default() -> Self {
613        Self {
614            combat_target_instance_id: None,
615            in_combat: false,
616            last_combat_tick: 0,
617            last_attack_tick: 0,
618            cooldowns_until_tick: BTreeMap::new(),
619            auto_attack_enabled: true,
620            mainhand_template_id: None,
621            mainhand_instance_id: None,
622            offhand_template_id: None,
623            offhand_instance_id: None,
624            worn: Vec::new(),
625            rotation_presets: Vec::new(),
626            target_slots: Vec::new(),
627            known_blueprint_ids: Vec::new(),
628            keychain: Vec::new(),
629            whisper_pouch: Vec::new(),
630            known_abilities: Vec::new(),
631            hotbar: Vec::new(),
632            abilities_schema_version: 0,
633            bank_balance_copper: 0,
634        }
635    }
636}
637
638#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
639pub struct EntityState {
640    pub id: EntityId,
641    pub transform: Transform,
642    /// Character / display name (first letter used as map glyph for other players).
643    #[serde(default)]
644    pub label: String,
645    #[serde(default)]
646    pub vitals: Option<PlayerVitals>,
647    /// Primary attributes (local player / inspect).
648    #[serde(default)]
649    pub attributes: Option<PrimaryAttributes>,
650    #[serde(default)]
651    pub skills: Option<PlayerSkills>,
652    /// When inside a building interior (players only).
653    #[serde(default)]
654    pub inside_building: Option<String>,
655    /// Gfx sprite sheet id for player avatars (`player.hero`, …).
656    #[serde(default)]
657    pub tile_id: Option<String>,
658    /// Paperdoll skin id (`assets/paperdoll/skins/`). Client prefers this over `tile_id` when baked.
659    #[serde(default)]
660    pub paperdoll_ref: Option<String>,
661    /// Canonical gfx presentation key (`walking`, `combat`, `harvesting`, …).
662    #[serde(default)]
663    pub presentation_state: Option<String>,
664    /// Resolved gfx sprite mode for `tile_id` (server-computed).
665    #[serde(default)]
666    pub sprite_mode: Option<String>,
667    /// XP pools for local player progression display (omitted on NPCs / other players).
668    #[serde(default)]
669    pub progression_xp: Option<ProgressionXp>,
670}
671
672/// In-world speech / stone contact channels.
673#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
674#[serde(rename_all = "snake_case")]
675pub enum ChatChannel {
676    /// Untargeted voice — everyone in acoustic range may hear.
677    Nearby,
678    /// Directed speak at a nearby player (eavesdroppers may overhear).
679    Direct,
680    /// Directed private whisper (range 0 for eavesdroppers).
681    Whisper,
682    /// Long-range contact via paired whisper stones ("phones").
683    WhisperStone,
684}
685
686/// How clearly this recipient heard the line (server-authored text already matches).
687#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
688#[serde(rename_all = "snake_case")]
689pub enum ChatClarity {
690    #[default]
691    Clear,
692    Partial,
693    Heavy,
694}
695
696#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
697pub struct ChatMessage {
698    pub channel: ChatChannel,
699    pub from_entity: EntityId,
700    pub from_name: String,
701    /// Text as this recipient should see it (already garbled when unclear).
702    pub text: String,
703    pub tick: Tick,
704    /// Directed / stone peer (omitted for untargeted Nearby).
705    #[serde(default)]
706    pub to_entity: Option<EntityId>,
707    #[serde(default)]
708    pub clarity: ChatClarity,
709}
710
711/// Client → server gameplay input (reliable, sequenced per entity).
712#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
713pub enum Intent {
714    Move {
715        entity_id: EntityId,
716        forward: f32,
717        strafe: f32,
718        /// Climb (+) or descend (−) on z axis (m/s intent).
719        #[serde(default)]
720        vertical: f32,
721        /// Sprint multiplier when stamina allows.
722        #[serde(default)]
723        sprint: bool,
724        seq: Seq,
725    },
726    Stop {
727        entity_id: EntityId,
728        seq: Seq,
729    },
730    Harvest {
731        entity_id: EntityId,
732        node_id: String,
733        seq: Seq,
734    },
735    Use {
736        entity_id: EntityId,
737        template_id: String,
738        seq: Seq,
739    },
740    /// Apply a grant consumable onto a specific item instance (unique gear status bindings).
741    UseGrant {
742        entity_id: EntityId,
743        grant_instance_id: Uuid,
744        target_instance_id: Uuid,
745        seq: Seq,
746    },
747    Say {
748        entity_id: EntityId,
749        channel: ChatChannel,
750        text: String,
751        /// Required for Direct / Whisper / WhisperStone (peer entity).
752        #[serde(default)]
753        to_entity: Option<EntityId>,
754        seq: Seq,
755    },
756    /// Start a blueprint craft (timed, like harvest).
757    Craft {
758        entity_id: EntityId,
759        blueprint_id: String,
760        /// Batches to run back-to-back; `None` crafts as many as materials/stamina allow.
761        #[serde(default)]
762        count: Option<u32>,
763        seq: Seq,
764    },
765    /// Door, NPC, enter/exit building.
766    Interact {
767        entity_id: EntityId,
768        target_id: String,
769        seq: Seq,
770    },
771    /// Buy from an NPC shop offer (`ShopOpened` catalog).
772    ShopBuy {
773        entity_id: EntityId,
774        npc_id: String,
775        offer_id: String,
776        #[serde(default = "default_one")]
777        quantity: u32,
778        seq: Seq,
779    },
780    /// Sell inventory to an NPC (`ShopOpened` buy list).
781    ShopSell {
782        entity_id: EntityId,
783        npc_id: String,
784        template_id: String,
785        #[serde(default = "default_one")]
786        quantity: u32,
787        seq: Seq,
788    },
789    /// Close an open NPC shop UI (releases the NPC movement pin).
790    ShopClose {
791        entity_id: EntityId,
792        npc_id: String,
793        seq: Seq,
794    },
795    /// Dev / test: apply damage to self (co-op testing).
796    TestDamage {
797        entity_id: EntityId,
798        amount: f32,
799        seq: Seq,
800    },
801    /// Slot-1 combat target (`plans/12` alias).
802    SetTarget {
803        entity_id: EntityId,
804        target_id: EntityId,
805        seq: Seq,
806    },
807    /// Target slot assignment (`plans/12` §5.3). Slot 1 aliases `SetTarget`.
808    SetTargetSlot {
809        entity_id: EntityId,
810        slot_index: u8,
811        target_id: EntityId,
812        seq: Seq,
813    },
814    ClearTarget {
815        entity_id: EntityId,
816        seq: Seq,
817    },
818    ClearTargetSlot {
819        entity_id: EntityId,
820        slot_index: u8,
821        seq: Seq,
822    },
823    /// Toggle per-slot auto-attack (`plans/12` §4.2).
824    SetAutoAttack {
825        entity_id: EntityId,
826        slot_index: u8,
827        enabled: bool,
828        seq: Seq,
829    },
830    /// Melee attack — uses `target_id` or the player's current target.
831    Attack {
832        entity_id: EntityId,
833        #[serde(default)]
834        target_id: Option<EntityId>,
835        #[serde(default)]
836        weapon_slot: Option<u32>,
837        seq: Seq,
838    },
839    /// Pick up a ground loot pile (nearest in range when `drop_id` omitted).
840    Pickup {
841        entity_id: EntityId,
842        #[serde(default)]
843        drop_id: Option<String>,
844        seq: Seq,
845    },
846    /// Cast a spell or use a non-weapon ability template (`plans/24` §4.3b).
847    Cast {
848        entity_id: EntityId,
849        ability_id: String,
850        target_id: EntityId,
851        seq: Seq,
852    },
853    /// Bind an ability to a target slot action bar (`plans/12` §4.2).
854    BindActionSlot {
855        entity_id: EntityId,
856        slot_index: u8,
857        ability_id: String,
858        #[serde(default = "default_auto_attack")]
859        auto_enabled: bool,
860        seq: Seq,
861    },
862    /// Fire a bound consumable or ability from a slot (`plans/24` §4.3c).
863    UseActionSlot {
864        entity_id: EntityId,
865        slot_index: u8,
866        seq: Seq,
867    },
868    /// Dodge — brief i-frames, stamina cost (`plans/24` §4.3c).
869    Dodge {
870        entity_id: EntityId,
871        seq: Seq,
872    },
873    /// Lunge — burst forward in movement/facing direction, higher stamina cost.
874    Lunge {
875        entity_id: EntityId,
876        /// Last movement forward axis when idle (−1..1).
877        #[serde(default)]
878        forward: f32,
879        /// Last movement strafe axis when idle (−1..1).
880        #[serde(default)]
881        strafe: f32,
882        seq: Seq,
883    },
884    /// Directional jump — leap one cell uphill over a medium cliff (`plans/04` §4.5b).
885    DirectionalJump {
886        entity_id: EntityId,
887        /// Jump direction forward axis (−1..1).
888        #[serde(default)]
889        forward: f32,
890        /// Jump direction strafe axis (−1..1).
891        #[serde(default)]
892        strafe: f32,
893        seq: Seq,
894    },
895    /// Block — frontal mitigation while held (`plans/24` §4.3c).
896    Block {
897        entity_id: EntityId,
898        #[serde(default = "default_block_enabled")]
899        enabled: bool,
900        seq: Seq,
901    },
902    /// Equip or clear mainhand weapon (`plans/26` §C2b). `None` unequips.
903    /// Two-handed weapons (`hand_slots: 2`) clear offhand on equip.
904    /// Prefer `instance_id` so unique bindings travel with that weapon instance.
905    EquipMainhand {
906        entity_id: EntityId,
907        #[serde(default)]
908        template_id: Option<String>,
909        #[serde(default)]
910        instance_id: Option<Uuid>,
911        seq: Seq,
912    },
913    /// Equip or clear offhand (shield / dual-wield). Rejected while mainhand is two-handed.
914    EquipOffhand {
915        entity_id: EntityId,
916        #[serde(default)]
917        template_id: Option<String>,
918        #[serde(default)]
919        instance_id: Option<Uuid>,
920        seq: Seq,
921    },
922    /// Equip a wearable (container, belt, armor, jewelry) to a body slot, or clear the
923    /// slot when `instance_id` is None.
924    EquipWorn {
925        entity_id: EntityId,
926        slot: BodySlot,
927        #[serde(default)]
928        instance_id: Option<Uuid>,
929        seq: Seq,
930    },
931    /// Move an item instance between root / worn / placed container inventories.
932    MoveItem {
933        entity_id: EntityId,
934        item_instance_id: Uuid,
935        from: InventoryLocation,
936        to: InventoryLocation,
937        /// When moving into a container location, nest under this parent instance (None = container root).
938        #[serde(default)]
939        to_parent_instance_id: Option<Uuid>,
940        /// Units to move; `None` moves the whole stack. Server clamps to volume/carry limits.
941        #[serde(default)]
942        quantity: Option<u32>,
943        seq: Seq,
944    },
945    /// Place a placeable container from inventory onto the ground at the player's feet.
946    PlaceContainer {
947        entity_id: EntityId,
948        item_instance_id: Uuid,
949        seq: Seq,
950    },
951    /// Pick up a placed container (with contents) into inventory.
952    PickupContainer {
953        entity_id: EntityId,
954        container_id: String,
955        seq: Seq,
956    },
957    /// Lock or unlock a worn or placed container (requires matching key when locking/unlocking).
958    SetContainerLocked {
959        entity_id: EntityId,
960        /// Worn slot or placed container id encoded as location.
961        location: InventoryLocation,
962        locked: bool,
963        seq: Seq,
964    },
965    /// Drop a non-container item on the ground at the player's feet.
966    DropItem {
967        entity_id: EntityId,
968        item_instance_id: Uuid,
969        from: InventoryLocation,
970        seq: Seq,
971    },
972    /// Permanently destroy an item stack (not recoverable; no ground drop).
973    DestroyItem {
974        entity_id: EntityId,
975        item_instance_id: Uuid,
976        from: InventoryLocation,
977        /// Units to destroy; `None` destroys the whole stack.
978        #[serde(default)]
979        quantity: Option<u32>,
980        seq: Seq,
981    },
982    /// Set a custom display name on a container instance (chest, pouch, backpack).
983    RenameContainer {
984        entity_id: EntityId,
985        item_instance_id: Uuid,
986        location: InventoryLocation,
987        name: String,
988        seq: Seq,
989    },
990    /// Create or update a rotation preset in the player's library.
991    UpsertRotationPreset {
992        entity_id: EntityId,
993        preset: RotationPreset,
994        seq: Seq,
995    },
996    /// Remove a rotation preset from the library.
997    DeleteRotationPreset {
998        entity_id: EntityId,
999        preset_id: String,
1000        seq: Seq,
1001    },
1002    /// Assign a library preset to target slot T1/T2.
1003    AssignSlotPreset {
1004        entity_id: EntityId,
1005        slot_index: u8,
1006        preset_id: String,
1007        seq: Seq,
1008    },
1009    /// Bind or clear a hotbar slot (`1`–`9`) to a learned / weapon ability
1010    /// or an inventory consumable (`item:<template_id>` — see [`hotbar_consumable_binding`]).
1011    SetHotbarSlot {
1012        entity_id: EntityId,
1013        /// 1–9
1014        slot: u8,
1015        /// `None` clears the slot. Ability id, or `item:<template_id>` for consumables.
1016        #[serde(default)]
1017        ability_id: Option<String>,
1018        seq: Seq,
1019    },
1020    /// Fire the next ready ability in a slot's rotation (manual step).
1021    AdvanceRotation {
1022        entity_id: EntityId,
1023        slot_index: u8,
1024        seq: Seq,
1025    },
1026    /// Open a turn-based conversation with an NPC.
1027    NpcTalkOpen {
1028        entity_id: EntityId,
1029        npc_id: String,
1030        seq: Seq,
1031    },
1032    /// Send a player message in an open NPC conversation.
1033    NpcTalkSay {
1034        entity_id: EntityId,
1035        npc_id: String,
1036        message: String,
1037        seq: Seq,
1038    },
1039    /// Close an NPC conversation.
1040    NpcTalkClose {
1041        entity_id: EntityId,
1042        npc_id: String,
1043        seq: Seq,
1044    },
1045    /// Accept a discovered quest (adds to active list).
1046    AcceptQuest {
1047        entity_id: EntityId,
1048        quest_id: String,
1049        seq: Seq,
1050    },
1051    /// Withdraw from an active quest (resets progress; can re-accept).
1052    WithdrawQuest {
1053        entity_id: EntityId,
1054        quest_id: String,
1055        seq: Seq,
1056    },
1057    /// Highlight an active quest in the HUD.
1058    TrackQuest {
1059        entity_id: EntityId,
1060        quest_id: String,
1061        seq: Seq,
1062    },
1063    /// Turn in items for an active give_item objective while near an NPC.
1064    QuestGiveItem {
1065        entity_id: EntityId,
1066        npc_id: String,
1067        template_id: String,
1068        #[serde(default = "default_one")]
1069        quantity: u32,
1070        seq: Seq,
1071    },
1072    /// Hire an NPC worker (fee + recurring wage).
1073    HireWorker {
1074        entity_id: EntityId,
1075        def_id: String,
1076        wage_copper_per_interval: u32,
1077        #[serde(default)]
1078        lodging_container_id: Option<String>,
1079        #[serde(default)]
1080        job_yaml: Option<String>,
1081        seq: Seq,
1082    },
1083    /// Release a hired worker instance.
1084    DismissWorker {
1085        entity_id: EntityId,
1086        worker_instance_id: String,
1087        seq: Seq,
1088    },
1089    /// Replace or assign a worker job YAML loop.
1090    SetWorkerJob {
1091        entity_id: EntityId,
1092        worker_instance_id: String,
1093        job_yaml: String,
1094        seq: Seq,
1095    },
1096    /// Point a worker at a camp bed / lodging container.
1097    AssignWorkerLodging {
1098        entity_id: EntityId,
1099        worker_instance_id: String,
1100        lodging_container_id: String,
1101        seq: Seq,
1102    },
1103    /// Switch companion vs job-loop automation mode.
1104    SetWorkerMode {
1105        entity_id: EntityId,
1106        worker_instance_id: String,
1107        mode: String,
1108        seq: Seq,
1109    },
1110    /// Hand an item from the player's inventory to a hired worker (e.g. a tool the
1111    /// worker must carry but not consume, like a handsaw for `oak_to_lumber`).
1112    GiveWorkerItem {
1113        entity_id: EntityId,
1114        worker_instance_id: String,
1115        item_instance_id: uuid::Uuid,
1116        #[serde(default)]
1117        quantity: Option<u32>,
1118        seq: Seq,
1119    },
1120    /// Take an item from a hired worker's inventory back into the employer's root.
1121    TakeWorkerItem {
1122        entity_id: EntityId,
1123        worker_instance_id: String,
1124        item_instance_id: uuid::Uuid,
1125        #[serde(default)]
1126        quantity: Option<u32>,
1127        seq: Seq,
1128    },
1129    /// Set a custom display name for a hired worker (shown in menus / route editor).
1130    RenameHiredWorker {
1131        entity_id: EntityId,
1132        worker_instance_id: String,
1133        name: String,
1134        seq: Seq,
1135    },
1136    /// Teach a known blueprint to a hired worker (costs `worker_train_copper`).
1137    TeachWorkerBlueprint {
1138        entity_id: EntityId,
1139        worker_instance_id: String,
1140        blueprint_id: String,
1141        seq: Seq,
1142    },
1143    /// Buy an unowned crown property parcel (plan 37).
1144    BuyProperty {
1145        entity_id: EntityId,
1146        zone_id: String,
1147        seq: Seq,
1148    },
1149    /// Sell an owned property parcel back to the crown (plan 37).
1150    SellProperty {
1151        entity_id: EntityId,
1152        zone_id: String,
1153        seq: Seq,
1154    },
1155    /// Deposit physical coins into the bank ledger at a teller (`plans/08` §8).
1156    BankDeposit {
1157        entity_id: EntityId,
1158        npc_id: String,
1159        /// Copper to deposit; `0` means deposit all on-person copper.
1160        #[serde(default)]
1161        amount_copper: u64,
1162        seq: Seq,
1163    },
1164    /// Withdraw copper from the bank ledger as physical coins at a teller.
1165    BankWithdraw {
1166        entity_id: EntityId,
1167        npc_id: String,
1168        /// Copper to withdraw; `0` means withdraw all bank balance.
1169        #[serde(default)]
1170        amount_copper: u64,
1171        seq: Seq,
1172    },
1173    /// Close the bank teller UI.
1174    BankClose {
1175        entity_id: EntityId,
1176        npc_id: String,
1177        seq: Seq,
1178    },
1179    /// Magical clearinghouse transfer to another character's bank ledger (`plans/08` §8.3).
1180    BankTransfer {
1181        entity_id: EntityId,
1182        npc_id: String,
1183        /// Recipient character id (preferred when known).
1184        #[serde(default)]
1185        to_character_id: Option<Uuid>,
1186        /// Fallback: match an online player's display name (case-insensitive).
1187        #[serde(default)]
1188        to_name: String,
1189        /// Copper to send (fee is extra, taken from sender bank balance).
1190        amount_copper: u64,
1191        seq: Seq,
1192    },
1193    /// Store an on-person item into the town storage vault at a storage manager.
1194    StorageStore {
1195        entity_id: EntityId,
1196        npc_id: String,
1197        item_instance_id: Uuid,
1198        #[serde(default)]
1199        quantity: Option<u32>,
1200        seq: Seq,
1201    },
1202    /// Take an item from the town storage vault onto person.
1203    StorageTake {
1204        entity_id: EntityId,
1205        npc_id: String,
1206        item_instance_id: Uuid,
1207        #[serde(default)]
1208        quantity: Option<u32>,
1209        seq: Seq,
1210    },
1211    /// Ship vault items to another storage building (distance fee + travel time).
1212    StorageShip {
1213        entity_id: EntityId,
1214        npc_id: String,
1215        dest_building_id: String,
1216        item_instance_id: Uuid,
1217        #[serde(default)]
1218        quantity: Option<u32>,
1219        seq: Seq,
1220    },
1221    /// Close the storage manager UI.
1222    StorageClose {
1223        entity_id: EntityId,
1224        npc_id: String,
1225        seq: Seq,
1226    },
1227    /// Request a player-to-player trade with a nearby peer.
1228    TradeRequest {
1229        entity_id: EntityId,
1230        peer_entity_id: EntityId,
1231        seq: Seq,
1232    },
1233    /// Accept or decline a pending trade request.
1234    TradeRespond {
1235        entity_id: EntityId,
1236        peer_entity_id: EntityId,
1237        accept: bool,
1238        seq: Seq,
1239    },
1240    /// Move an inventory item into the trade escrow (presented bucket).
1241    TradePresent {
1242        entity_id: EntityId,
1243        item_instance_id: Uuid,
1244        #[serde(default)]
1245        quantity: Option<u32>,
1246        seq: Seq,
1247    },
1248    /// Return a presented item from escrow to inventory.
1249    TradeUnpresent {
1250        entity_id: EntityId,
1251        item_instance_id: Uuid,
1252        seq: Seq,
1253    },
1254    /// Toggle ready / handshake on the open trade.
1255    TradeSetReady {
1256        entity_id: EntityId,
1257        ready: bool,
1258        seq: Seq,
1259    },
1260    /// Cancel the open trade (or withdraw a pending request).
1261    TradeCancel {
1262        entity_id: EntityId,
1263        seq: Seq,
1264    },
1265    /// Destroy a paired whisper stone from the whisper pouch (cut contact).
1266    DestroyWhisperStone {
1267        entity_id: EntityId,
1268        item_instance_id: Uuid,
1269        seq: Seq,
1270    },
1271    /// Stow a blank or paired whisper stone from inventory into the pouch.
1272    StowWhisperStone {
1273        entity_id: EntityId,
1274        item_instance_id: Uuid,
1275        seq: Seq,
1276    },
1277}
1278
1279fn default_block_enabled() -> bool {
1280    true
1281}
1282
1283/// One active status effect for HUD (buff/debuff icon strip).
1284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1285pub struct StatusEffectHud {
1286    pub effect_id: String,
1287    pub label: String,
1288    #[serde(default)]
1289    pub polarity: String,
1290    #[serde(default)]
1291    pub icon_tile_id: Option<String>,
1292    /// Remaining duration in seconds (`None` when permanent while-equipped).
1293    #[serde(default)]
1294    pub remaining_sec: Option<f32>,
1295}
1296
1297/// Observer combat HUD (local player only).
1298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1299pub struct CombatTargetHud {
1300    pub entity_id: EntityId,
1301    #[serde(default)]
1302    pub label: String,
1303    #[serde(default)]
1304    pub level: u32,
1305    pub health: f32,
1306    pub health_max: f32,
1307    #[serde(default)]
1308    pub life_state: LifeState,
1309    #[serde(default)]
1310    pub distance_m: f32,
1311    #[serde(default)]
1312    pub statuses: Vec<StatusEffectHud>,
1313}
1314
1315/// Active spell cast channel progress.
1316#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1317pub struct CastProgressHud {
1318    #[serde(default)]
1319    pub ability_id: String,
1320    #[serde(default)]
1321    pub ability_label: String,
1322    #[serde(default)]
1323    pub ticks_remaining: u64,
1324    #[serde(default)]
1325    pub ticks_total: u64,
1326}
1327
1328/// Cooldown state for a combat ability shown on the action bar.
1329#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1330pub struct AbilityCooldownHud {
1331    #[serde(default)]
1332    pub ability_id: String,
1333    #[serde(default)]
1334    pub label: String,
1335    #[serde(default)]
1336    pub cd_ticks: u64,
1337    #[serde(default)]
1338    pub cd_total_ticks: u64,
1339}
1340
1341/// One combat target slot in the observer HUD.
1342#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1343pub struct CombatSlotHud {
1344    pub slot_index: u8,
1345    #[serde(default)]
1346    pub target_entity_id: Option<EntityId>,
1347    #[serde(default)]
1348    pub target_label: Option<String>,
1349    #[serde(default)]
1350    pub target: Option<CombatTargetHud>,
1351    #[serde(default)]
1352    pub preset_id: Option<String>,
1353    #[serde(default)]
1354    pub preset_label: Option<String>,
1355    #[serde(default)]
1356    pub rotation: Vec<String>,
1357    #[serde(default)]
1358    pub rotation_index: u32,
1359    #[serde(default)]
1360    pub next_ability_id: Option<String>,
1361    #[serde(default)]
1362    pub auto_enabled: bool,
1363}
1364
1365/// One worn piece contribution in the defense breakdown.
1366#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1367pub struct DefensePieceHud {
1368    pub slot: BodySlot,
1369    pub label: String,
1370    pub template_id: String,
1371    #[serde(default)]
1372    pub armor_physical: f32,
1373    #[serde(default)]
1374    pub resists: Vec<(String, f32)>,
1375}
1376
1377impl Default for DefensePieceHud {
1378    fn default() -> Self {
1379        Self {
1380            slot: BodySlot::Head,
1381            label: String::new(),
1382            template_id: String::new(),
1383            armor_physical: 0.0,
1384            resists: Vec::new(),
1385        }
1386    }
1387}
1388
1389/// Aggregated mitigation / resist summary for the local player Equip UI.
1390#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1391pub struct DefenseHud {
1392    pub armor_physical: f32,
1393    pub vitality_contribution: f32,
1394    pub total_mitigation_rating: f32,
1395    /// `rating / (rating + K)` physical damage reduction fraction.
1396    pub estimated_physical_dr: f32,
1397    #[serde(default)]
1398    pub resists: Vec<(String, f32)>,
1399    #[serde(default)]
1400    pub pieces: Vec<DefensePieceHud>,
1401}
1402
1403/// Observer combat HUD (local player only).
1404#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1405pub struct CombatHud {
1406    pub in_combat: bool,
1407    /// T1 auto rotation (legacy field mirrors slot 1).
1408    pub auto_attack: bool,
1409    pub has_los: bool,
1410    pub attack_cd_ticks: u64,
1411    #[serde(default)]
1412    pub ability_id: String,
1413    #[serde(default)]
1414    pub target_entity_id: Option<EntityId>,
1415    #[serde(default)]
1416    pub target_label: Option<String>,
1417    #[serde(default)]
1418    pub max_target_slots: u8,
1419    #[serde(default)]
1420    pub slots: Vec<CombatSlotHud>,
1421    #[serde(default)]
1422    pub rotation_presets: Vec<RotationPreset>,
1423    #[serde(default)]
1424    pub gcd_ticks: u64,
1425    #[serde(default)]
1426    pub mainhand_template_id: Option<String>,
1427    #[serde(default)]
1428    pub mainhand_label: Option<String>,
1429    #[serde(default)]
1430    pub offhand_template_id: Option<String>,
1431    #[serde(default)]
1432    pub offhand_label: Option<String>,
1433    /// Mainhand occupies this many hand sockets (`1` or `2`).
1434    #[serde(default)]
1435    pub mainhand_hand_slots: u8,
1436    /// Equipped body-slot items (backpack, belt, armor, jewelry).
1437    #[serde(default)]
1438    pub worn: Vec<(BodySlot, ItemStack)>,
1439    /// Live mitigation / resist summary for the Equip paperdoll.
1440    #[serde(default)]
1441    pub defense: Option<DefenseHud>,
1442    #[serde(default)]
1443    pub carry_mass: f32,
1444    #[serde(default)]
1445    pub carry_mass_max: f32,
1446    #[serde(default)]
1447    pub encumbrance: EncumbranceState,
1448    /// Keys on the virtual keychain (stowed, zero carry mass).
1449    #[serde(default)]
1450    pub keychain: Vec<ItemStack>,
1451    /// Paired whisper stones on the virtual pouch (stowed, zero carry mass).
1452    #[serde(default)]
1453    pub whisper_pouch: Vec<ItemStack>,
1454    #[serde(default)]
1455    pub target: Option<CombatTargetHud>,
1456    #[serde(default)]
1457    pub cast: Option<CastProgressHud>,
1458    #[serde(default)]
1459    pub ability_cooldowns: Vec<AbilityCooldownHud>,
1460    #[serde(default)]
1461    pub blocking_active: bool,
1462    /// Live XP pools for the local player (updated every tick with combat HUD).
1463    #[serde(default)]
1464    pub progression_xp: Option<ProgressionXp>,
1465    #[serde(default)]
1466    pub progression_baseline: u16,
1467    #[serde(default)]
1468    pub progression_xp_base: f64,
1469    #[serde(default)]
1470    pub progression_xp_growth: f64,
1471    #[serde(default)]
1472    pub attributes: Option<PrimaryAttributes>,
1473    #[serde(default)]
1474    pub skills: Option<PlayerSkills>,
1475    /// Active status effects on the local player.
1476    #[serde(default)]
1477    pub statuses: Vec<StatusEffectHud>,
1478    /// Learned abilities currently usable (permanent + unexpired temporary).
1479    #[serde(default)]
1480    pub known_abilities: Vec<String>,
1481    /// Hotbar bindings for keys 1–9 (index 0 = key 1).
1482    /// Ability ids, or `item:<template_id>` for consumables ([`hotbar_consumable_binding`]).
1483    #[serde(default)]
1484    pub hotbar: Vec<Option<String>>,
1485    /// Max abilities allowed in one rotation (INT+WIS mind score).
1486    #[serde(default)]
1487    pub max_abilities_per_rotation: u8,
1488}
1489
1490/// Prefix for hotbar slots bound to inventory consumables (`item:health_potion`).
1491pub const HOTBAR_ITEM_PREFIX: &str = "item:";
1492
1493/// Encode a consumable template id for [`Intent::SetHotbarSlot`] / combat HUD hotbar.
1494pub fn hotbar_consumable_binding(template_id: &str) -> String {
1495    format!("{HOTBAR_ITEM_PREFIX}{}", template_id.trim())
1496}
1497
1498/// If `binding` is an `item:` consumable slot, return the template id.
1499pub fn hotbar_consumable_template(binding: &str) -> Option<&str> {
1500    binding
1501        .strip_prefix(HOTBAR_ITEM_PREFIX)
1502        .map(str::trim)
1503        .filter(|id| !id.is_empty())
1504}
1505
1506/// True when a hotbar binding string refers to a consumable (not an ability).
1507pub fn hotbar_binding_is_consumable(binding: &str) -> bool {
1508    hotbar_consumable_template(binding).is_some()
1509}
1510
1511/// Spatial combat cue for map overlays (`plans/39`).
1512#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1513#[serde(rename_all = "snake_case")]
1514pub enum CombatFxKind {
1515    MeleeArc,
1516    Cone,
1517    Sphere,
1518    Beam,
1519    HitMarker,
1520}
1521
1522/// Outcome attached to a hit marker / struck entity.
1523#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1524#[serde(rename_all = "snake_case")]
1525pub enum CombatFxHitOutcome {
1526    #[default]
1527    Hit,
1528    Blocked,
1529    Miss,
1530    Glance,
1531}
1532
1533/// One entity struck (or targeted) by a combat FX resolve.
1534#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1535pub struct CombatFxHit {
1536    pub entity_id: EntityId,
1537    pub x: f32,
1538    pub y: f32,
1539    pub z: f32,
1540    #[serde(default)]
1541    pub outcome: CombatFxHitOutcome,
1542}
1543
1544/// Authoritative attack footprint / hit cue for clients to draw on the map.
1545#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1546pub struct CombatFx {
1547    pub id: u64,
1548    pub kind: CombatFxKind,
1549    pub ability_id: String,
1550    pub caster_id: EntityId,
1551    pub origin_x: f32,
1552    pub origin_y: f32,
1553    pub origin_z: f32,
1554    #[serde(default)]
1555    pub end_x: Option<f32>,
1556    #[serde(default)]
1557    pub end_y: Option<f32>,
1558    #[serde(default)]
1559    pub end_z: Option<f32>,
1560    #[serde(default)]
1561    pub yaw: Option<f32>,
1562    #[serde(default)]
1563    pub reach_m: Option<f32>,
1564    #[serde(default)]
1565    pub arc_deg: Option<f32>,
1566    #[serde(default)]
1567    pub radius_m: Option<f32>,
1568    #[serde(default)]
1569    pub hits: Vec<CombatFxHit>,
1570    /// Sim tick after which clients should drop this cue.
1571    pub until_tick: u64,
1572    #[serde(default)]
1573    pub damage_type: String,
1574}
1575
1576/// Hired worker automation mode on the wire.
1577#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1578#[serde(rename_all = "snake_case")]
1579pub enum WorkerModeView {
1580    Companion,
1581    JobLoop,
1582    /// Deliberately parked — no route, no follow. The worker stands down
1583    /// (still draws wages) until the employer assigns a mode/route again.
1584    Idle,
1585}
1586
1587/// Hired worker FSM state on the wire.
1588#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1589#[serde(rename_all = "snake_case")]
1590pub enum WorkerStateView {
1591    Idle,
1592    Traveling,
1593    Working,
1594    Resting,
1595    Waiting,
1596    Strike,
1597    Dismissed,
1598}
1599
1600/// Compact vitals for hired worker HUD rows.
1601#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
1602pub struct WorkerVitalsSummary {
1603    pub health_pct: f32,
1604    pub stamina_pct: f32,
1605}
1606
1607/// High-level route shape — `harvest_loop` (legacy flat lists) or `ordered` (typed stop list).
1608#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1609#[serde(rename_all = "snake_case")]
1610pub enum WorkerRouteKindView {
1611    #[default]
1612    HarvestLoop,
1613    Ordered,
1614}
1615
1616/// Saved worker route for UI / route editor reload.
1617#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1618pub struct WorkerRouteView {
1619    #[serde(default)]
1620    pub kind: WorkerRouteKindView,
1621    #[serde(default)]
1622    pub lodging_container_id: Option<String>,
1623    /// Legacy `harvest_loop` waypoint list.
1624    #[serde(default)]
1625    pub outbound_waypoints: Vec<WorkerRouteWaypointView>,
1626    /// Legacy `harvest_loop` node id list.
1627    #[serde(default)]
1628    pub harvest_nodes: Vec<String>,
1629    #[serde(default = "default_route_carry_ratio")]
1630    pub carry_return_ratio: f32,
1631    /// Ordered-route typed stops (`kind: ordered`).
1632    #[serde(default)]
1633    pub stops: Vec<WorkerRouteStopView>,
1634}
1635
1636fn default_route_carry_ratio() -> f32 {
1637    0.90
1638}
1639
1640fn default_true_view() -> bool {
1641    true
1642}
1643
1644/// One withdraw line for a `WithdrawFrom` route stop view.
1645#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1646pub struct WorkerWithdrawItemView {
1647    pub template: String,
1648    #[serde(default)]
1649    pub qty: u32,
1650    /// Take every stack of this template (carry-capped). Wins over `qty`.
1651    #[serde(default)]
1652    pub all: bool,
1653}
1654
1655#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1656pub struct WorkerRouteWaypointView {
1657    pub x: f32,
1658    pub y: f32,
1659    pub z: f32,
1660}
1661
1662/// One typed stop in an ordered worker route.
1663///
1664/// NOTE: this is the wire (postcard) view. Postcard does **not** support
1665/// internally-tagged enums (`#[serde(tag = ...)]` — it returns `WontImplement`
1666/// from `deserialize_any`), so this enum uses serde's default **external tagging**.
1667/// The sim-side `WorkerRouteStop` (`crates/sim/src/worker_job.rs`) is the YAML-facing
1668/// twin and keeps its `tag = "stop"` for human-authored job YAML; the two never share
1669/// a wire format.
1670#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1671#[serde(rename_all = "snake_case")]
1672pub enum WorkerRouteStopView {
1673    Waypoint {
1674        x: f32,
1675        y: f32,
1676        #[serde(default)]
1677        z: f32,
1678    },
1679    HarvestNode {
1680        node_id: String,
1681    },
1682    DepositAt {
1683        container_id: String,
1684        #[serde(default)]
1685        filter: Option<Vec<String>>,
1686    },
1687    TradeWith {
1688        #[serde(default)]
1689        npc_id: Option<String>,
1690        template: String,
1691        #[serde(default = "default_true_view")]
1692        sell_all: bool,
1693    },
1694    WithdrawFrom {
1695        container_id: String,
1696        items: Vec<WorkerWithdrawItemView>,
1697    },
1698    CraftAt {
1699        device: String,
1700        blueprint: String,
1701        #[serde(default)]
1702        qty: Option<u32>,
1703    },
1704    RestIfNeeded,
1705    Wait {
1706        #[serde(default)]
1707        wait_ticks: u64,
1708    },
1709}
1710
1711/// Copper ledger category (expense negative / income positive on the wire entry).
1712#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1713#[serde(rename_all = "snake_case")]
1714pub enum LedgerCategory {
1715    Workers,
1716    Hire,
1717    Train,
1718    ShopBuy,
1719    Taxes,
1720    WorkerSales,
1721    TraderSales,
1722    BankDeposit,
1723    BankWithdraw,
1724    BankTransferOut,
1725    BankTransferIn,
1726    BankTransferFee,
1727    StorageShipFee,
1728    Other,
1729}
1730
1731impl LedgerCategory {
1732    pub fn as_str(self) -> &'static str {
1733        match self {
1734            Self::Workers => "workers",
1735            Self::Hire => "hire",
1736            Self::Train => "train",
1737            Self::ShopBuy => "shop_buy",
1738            Self::Taxes => "taxes",
1739            Self::WorkerSales => "worker_sales",
1740            Self::TraderSales => "trader_sales",
1741            Self::BankDeposit => "bank_deposit",
1742            Self::BankWithdraw => "bank_withdraw",
1743            Self::BankTransferOut => "bank_transfer_out",
1744            Self::BankTransferIn => "bank_transfer_in",
1745            Self::BankTransferFee => "bank_transfer_fee",
1746            Self::StorageShipFee => "storage_ship_fee",
1747            Self::Other => "other",
1748        }
1749    }
1750}
1751
1752#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1753pub struct LedgerEntryView {
1754    pub id: uuid::Uuid,
1755    pub game_day: u64,
1756    pub signed_copper: i64,
1757    pub category: LedgerCategory,
1758    #[serde(default)]
1759    pub label: String,
1760}
1761
1762#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1763pub struct LedgerPeriodTotals {
1764    /// Absolute copper spent by category key.
1765    #[serde(default)]
1766    pub expenses: std::collections::HashMap<String, u64>,
1767    /// Absolute copper earned by category key.
1768    #[serde(default)]
1769    pub income: std::collections::HashMap<String, u64>,
1770    pub expense_copper: u64,
1771    pub income_copper: u64,
1772    /// income − expenses (may be negative).
1773    pub cash_flow_copper: i64,
1774}
1775
1776#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1777pub struct PlayerLedgerView {
1778    pub current_game_day: u64,
1779    #[serde(default)]
1780    pub period_day: LedgerPeriodTotals,
1781    #[serde(default)]
1782    pub period_week: LedgerPeriodTotals,
1783    #[serde(default)]
1784    pub period_month: LedgerPeriodTotals,
1785    #[serde(default)]
1786    pub period_lifetime: LedgerPeriodTotals,
1787    #[serde(default)]
1788    pub recent: Vec<LedgerEntryView>,
1789    /// Copper on the character (inventory + worn bags).
1790    #[serde(default)]
1791    pub wealth_on_person_copper: u64,
1792    /// Copper in owned placed storage (chests, lodging — not bank ledger).
1793    #[serde(default)]
1794    pub wealth_in_storage_copper: u64,
1795    /// Copper in the secure bank ledger (`plans/08` §8).
1796    #[serde(default)]
1797    pub wealth_in_bank_copper: u64,
1798    /// `wealth_on_person_copper + wealth_in_storage_copper + wealth_in_bank_copper`.
1799    #[serde(default)]
1800    pub wealth_total_copper: u64,
1801    /// Live payroll burn (cp per wage interval) for hired workers.
1802    #[serde(default)]
1803    pub live_expense_per_interval_copper: u64,
1804    /// Estimated copper from one full worker job loop (broker sell steps).
1805    #[serde(default)]
1806    pub live_income_route_est_per_loop_copper: u64,
1807    /// Recent average income (worker/trader sales) per wage interval.
1808    #[serde(default)]
1809    pub live_income_avg_per_interval_copper: u64,
1810    /// Number of wage intervals in the rolling average window.
1811    #[serde(default)]
1812    pub live_income_avg_window_intervals: u32,
1813    /// `live_income_avg_per_interval_copper − live_expense_per_interval_copper`.
1814    #[serde(default)]
1815    pub live_net_avg_per_interval_copper: i64,
1816}
1817
1818/// Gameplay analytics metric keys (extensible string on the wire via rename).
1819#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1820#[serde(rename_all = "snake_case")]
1821pub enum AnalyticsMetric {
1822    NpcKill,
1823    WildlifeKill,
1824    Harvest,
1825    QuestComplete,
1826    QuestAccept,
1827    QuestAbandon,
1828    PlayerDeath,
1829    Craft,
1830    WorkerHire,
1831    WorkerDismiss,
1832    WorkerTeach,
1833    NpcTalk,
1834    ShopBuy,
1835    ShopSell,
1836    PlaceContainer,
1837    PickupContainer,
1838    PickupDrop,
1839    ConsumableUse,
1840    AbilityUse,
1841    DistanceWalkedM,
1842    DoorUse,
1843    BuildingEnter,
1844}
1845
1846impl AnalyticsMetric {
1847    pub fn as_str(self) -> &'static str {
1848        match self {
1849            Self::NpcKill => "npc_kill",
1850            Self::WildlifeKill => "wildlife_kill",
1851            Self::Harvest => "harvest",
1852            Self::QuestComplete => "quest_complete",
1853            Self::QuestAccept => "quest_accept",
1854            Self::QuestAbandon => "quest_abandon",
1855            Self::PlayerDeath => "player_death",
1856            Self::Craft => "craft",
1857            Self::WorkerHire => "worker_hire",
1858            Self::WorkerDismiss => "worker_dismiss",
1859            Self::WorkerTeach => "worker_teach",
1860            Self::NpcTalk => "npc_talk",
1861            Self::ShopBuy => "shop_buy",
1862            Self::ShopSell => "shop_sell",
1863            Self::PlaceContainer => "place_container",
1864            Self::PickupContainer => "pickup_container",
1865            Self::PickupDrop => "pickup_drop",
1866            Self::ConsumableUse => "consumable_use",
1867            Self::AbilityUse => "ability_use",
1868            Self::DistanceWalkedM => "distance_walked_m",
1869            Self::DoorUse => "door_use",
1870            Self::BuildingEnter => "building_enter",
1871        }
1872    }
1873
1874    pub fn from_str_key(s: &str) -> Option<Self> {
1875        Some(match s {
1876            "npc_kill" => Self::NpcKill,
1877            "wildlife_kill" => Self::WildlifeKill,
1878            "harvest" => Self::Harvest,
1879            "quest_complete" => Self::QuestComplete,
1880            "quest_accept" => Self::QuestAccept,
1881            "quest_abandon" => Self::QuestAbandon,
1882            "player_death" => Self::PlayerDeath,
1883            "craft" => Self::Craft,
1884            "worker_hire" => Self::WorkerHire,
1885            "worker_dismiss" => Self::WorkerDismiss,
1886            "worker_teach" => Self::WorkerTeach,
1887            "npc_talk" => Self::NpcTalk,
1888            "shop_buy" => Self::ShopBuy,
1889            "shop_sell" => Self::ShopSell,
1890            "place_container" => Self::PlaceContainer,
1891            "pickup_container" => Self::PickupContainer,
1892            "pickup_drop" => Self::PickupDrop,
1893            "consumable_use" => Self::ConsumableUse,
1894            "ability_use" => Self::AbilityUse,
1895            "distance_walked_m" => Self::DistanceWalkedM,
1896            "door_use" => Self::DoorUse,
1897            "building_enter" => Self::BuildingEnter,
1898            _ => return None,
1899        })
1900    }
1901}
1902
1903#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1904pub struct CareerMetricRow {
1905    pub subject_id: String,
1906    pub amount: u64,
1907}
1908
1909/// Personal analytics summary for the Character `i` Career tab.
1910#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1911pub struct PlayerCareerView {
1912    pub current_game_day: u64,
1913    #[serde(default)]
1914    pub kills: Vec<CareerMetricRow>,
1915    #[serde(default)]
1916    pub harvests: Vec<CareerMetricRow>,
1917    pub quests_completed: u64,
1918    #[serde(default)]
1919    pub crafts: Vec<CareerMetricRow>,
1920    pub deaths: u64,
1921    pub npc_talks: u64,
1922    pub shop_buys: u64,
1923    pub shop_sells: u64,
1924    pub distance_m: u64,
1925    #[serde(default)]
1926    pub other: Vec<CareerMetricRow>,
1927}
1928
1929/// One player-hired worker visible in snapshot / tick deltas.
1930#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1931pub struct HiredWorkerView {
1932    pub instance_id: String,
1933    pub entity_id: EntityId,
1934    pub def_id: String,
1935    /// Display label (custom name when set, otherwise the NPC def label).
1936    pub label: String,
1937    pub x: f32,
1938    pub y: f32,
1939    pub z: f32,
1940    pub mode: WorkerModeView,
1941    pub state: WorkerStateView,
1942    #[serde(default)]
1943    pub step_label: String,
1944    pub vitals: WorkerVitalsSummary,
1945    #[serde(default)]
1946    pub carry_pct: f32,
1947    #[serde(default)]
1948    pub last_error: Option<String>,
1949    pub wage_copper_per_interval: u32,
1950    /// Estimated wage this interval (base + loop effort, travel meters excluded).
1951    #[serde(default)]
1952    pub effective_wage_copper: u32,
1953    /// Meters walked toward the next wage debit.
1954    #[serde(default)]
1955    pub wage_meters_walked: f32,
1956    /// Placed camp bed / lodging container this worker uses for deposit and rest.
1957    #[serde(default)]
1958    pub lodging_container_id: Option<String>,
1959    /// High-level harvest route (when job_loop route is configured).
1960    #[serde(default)]
1961    pub route: Option<WorkerRouteView>,
1962    /// Index into `route.stops` for the worker's current job step (ordered routes).
1963    /// Maps expanded job steps (travel+deposit, etc.) back to the designer stop.
1964    #[serde(default)]
1965    pub route_stop_index: Option<u32>,
1966    /// Recipes this worker already knows (from hire `teaches` + employer teach).
1967    #[serde(default)]
1968    pub known_blueprint_ids: Vec<String>,
1969    /// Overall worker level (1+).
1970    #[serde(default = "default_worker_view_level")]
1971    pub level: u32,
1972    /// Cumulative worker XP.
1973    #[serde(default)]
1974    pub worker_xp: f64,
1975    /// Items the worker currently carries (employer-visible for give/take).
1976    #[serde(default)]
1977    pub inventory: Vec<ItemStack>,
1978}
1979
1980fn default_worker_view_level() -> u32 {
1981    1
1982}
1983
1984/// Server → client AOI-filtered entity updates for one sim tick.
1985#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1986pub struct TickDelta {
1987    pub tick: Tick,
1988    pub entities: Vec<EntityState>,
1989    #[serde(default)]
1990    pub resource_nodes: Vec<ResourceNodeView>,
1991    #[serde(default)]
1992    pub buildings: Vec<BuildingView>,
1993    #[serde(default)]
1994    pub doors: Vec<DoorView>,
1995    #[serde(default)]
1996    pub npcs: Vec<NpcView>,
1997    /// Observer inventory stacks (template → qty).
1998    #[serde(default)]
1999    pub inventory: Vec<ItemStack>,
2000    #[serde(default)]
2001    pub blueprints: Vec<BlueprintView>,
2002    #[serde(default)]
2003    pub world_clock: WorldClock,
2004    #[serde(default)]
2005    pub ground_drops: Vec<GroundDropView>,
2006    #[serde(default)]
2007    pub placed_containers: Vec<PlacedContainerView>,
2008    #[serde(default)]
2009    pub combat: Option<CombatHud>,
2010    #[serde(default)]
2011    pub interior_map: Option<InteriorMapView>,
2012    #[serde(default)]
2013    pub quest_log: Vec<QuestLogEntry>,
2014    #[serde(default)]
2015    pub hired_workers: Vec<HiredWorkerView>,
2016    #[serde(default)]
2017    pub interactables: Vec<InteractableView>,
2018    #[serde(default)]
2019    pub ledger: Option<PlayerLedgerView>,
2020    #[serde(default)]
2021    pub career: Option<PlayerCareerView>,
2022    /// Active combat footprints / hit markers in observer AOI (`plans/39`).
2023    #[serde(default)]
2024    pub combat_fx: Vec<CombatFx>,
2025}
2026#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2027pub struct GroundDropView {
2028    pub id: String,
2029    pub template_id: String,
2030    pub quantity: u32,
2031    pub x: f32,
2032    pub y: f32,
2033    pub z: f32,
2034    /// Optional gfx tile id from the item template.
2035    #[serde(default)]
2036    pub tile_id: Option<String>,
2037    /// Optional paperdoll skin id from the item template (preferred over `tile_id` in gfx).
2038    #[serde(default)]
2039    pub paperdoll_ref: Option<String>,
2040    /// Paperdoll facing yaw in radians (0 = north).
2041    #[serde(default)]
2042    pub yaw: f32,
2043    /// Paperdoll pitch in radians (0 = upright).
2044    #[serde(default)]
2045    pub pitch: f32,
2046    /// Paperdoll roll in radians (0 = upright; tilts in the view plane).
2047    #[serde(default)]
2048    pub roll: f32,
2049    /// Draw scale relative to one map cell (1.0 = cell size).
2050    #[serde(default = "default_draw_scale")]
2051    pub draw_scale: f32,
2052}
2053
2054fn default_draw_scale() -> f32 {
2055    1.0
2056}
2057
2058/// Full state on region enter or reconnect.
2059#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2060pub struct Snapshot {
2061    pub tick: Tick,
2062    pub chunk_rev: u64,
2063    /// Bumped when blueprints, catalog, segment, or settings reload.
2064    #[serde(default)]
2065    pub content_rev: u64,
2066    /// Stable publish revision from `assets/.content-publish.json` (for client asset sync).
2067    #[serde(default)]
2068    pub publish_rev: u64,
2069    pub entities: Vec<EntityState>,
2070    #[serde(default)]
2071    pub resource_nodes: Vec<ResourceNodeView>,
2072    /// Segment play area width in meters (for HUD).
2073    #[serde(default)]
2074    pub world_width_m: f32,
2075    #[serde(default)]
2076    pub world_height_m: f32,
2077    #[serde(default)]
2078    pub buildings: Vec<BuildingView>,
2079    #[serde(default)]
2080    pub doors: Vec<DoorView>,
2081    #[serde(default)]
2082    pub npcs: Vec<NpcView>,
2083    #[serde(default)]
2084    pub inventory: Vec<ItemStack>,
2085    #[serde(default)]
2086    pub blueprints: Vec<BlueprintView>,
2087    #[serde(default)]
2088    pub world_clock: WorldClock,
2089    #[serde(default)]
2090    pub terrain_zones: Vec<TerrainZoneView>,
2091    #[serde(default)]
2092    pub z_platforms: Vec<ZPlatformView>,
2093    #[serde(default)]
2094    pub z_transitions: Vec<ZTransitionView>,
2095    #[serde(default)]
2096    pub ground_drops: Vec<GroundDropView>,
2097    #[serde(default)]
2098    pub placed_containers: Vec<PlacedContainerView>,
2099    #[serde(default)]
2100    pub combat: Option<CombatHud>,
2101    #[serde(default)]
2102    pub interior_map: Option<InteriorMapView>,
2103    #[serde(default)]
2104    pub quest_log: Vec<QuestLogEntry>,
2105    #[serde(default)]
2106    pub hired_workers: Vec<HiredWorkerView>,
2107    #[serde(default)]
2108    pub interactables: Vec<InteractableView>,
2109    #[serde(default)]
2110    pub ledger: Option<PlayerLedgerView>,
2111    #[serde(default)]
2112    pub career: Option<PlayerCareerView>,
2113    /// Active combat footprints / hit markers (`plans/39`).
2114    #[serde(default)]
2115    pub combat_fx: Vec<CombatFx>,
2116}
2117
2118/// Harvestable node visible to clients.
2119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2120pub struct ResourceNodeView {
2121    pub id: String,
2122    pub label: String,
2123    pub x: f32,
2124    pub y: f32,
2125    pub z: f32,
2126    pub item_template: String,
2127    #[serde(default = "default_node_state")]
2128    pub state: ResourceNodeState,
2129    /// When true, players cannot walk through this node while available/harvesting.
2130    #[serde(default = "default_blocking_view")]
2131    pub blocking: bool,
2132    /// Collision radius for pathfinding (meters).
2133    #[serde(default = "default_blocking_radius_view")]
2134    pub blocking_radius_m: f32,
2135    /// Optional gfx tile id (`resource.oak_log`, …).
2136    #[serde(default)]
2137    pub tile_id: Option<String>,
2138    /// Optional paperdoll skin id from the item template (preferred over `tile_id` in gfx).
2139    #[serde(default)]
2140    pub paperdoll_ref: Option<String>,
2141    /// Paperdoll facing yaw in radians (0 = north). From map placement.
2142    #[serde(default)]
2143    pub yaw: f32,
2144    /// Paperdoll pitch in radians (0 = upright). From map placement.
2145    #[serde(default)]
2146    pub pitch: f32,
2147    /// Paperdoll roll in radians (0 = upright). From map placement.
2148    #[serde(default)]
2149    pub roll: f32,
2150    /// Draw scale relative to one map cell (1.0 = cell size).
2151    #[serde(default = "default_draw_scale")]
2152    pub draw_scale: f32,
2153    /// Resolved gfx sprite mode for `tile_id` (server-computed).
2154    #[serde(default)]
2155    pub sprite_mode: Option<String>,
2156    /// Canonical presentation key (`available`, `harvesting`, `depleted`).
2157    #[serde(default)]
2158    pub presentation_state: Option<String>,
2159}
2160
2161fn default_blocking_radius_view() -> f32 {
2162    0.8
2163}
2164
2165fn default_blocking_view() -> bool {
2166    true
2167}
2168
2169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2170#[serde(rename_all = "snake_case")]
2171pub enum ResourceNodeState {
2172    Available,
2173    Harvesting,
2174    Cooldown,
2175}
2176
2177fn default_node_state() -> ResourceNodeState {
2178    ResourceNodeState::Available
2179}
2180
2181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2182#[serde(rename_all = "snake_case")]
2183pub enum ItemStatusBindingMode {
2184    OnHit,
2185    WhileEquipped,
2186}
2187
2188impl Default for ItemStatusBindingMode {
2189    fn default() -> Self {
2190        Self::OnHit
2191    }
2192}
2193
2194/// Per-instance status effect binding (unique gear grants / enchantments).
2195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2196pub struct ItemStatusBinding {
2197    pub effect_id: String,
2198    #[serde(default)]
2199    pub mode: ItemStatusBindingMode,
2200    /// Grant template id, `"loot"`, later `"altar"`, etc.
2201    #[serde(default)]
2202    pub source: String,
2203    #[serde(default)]
2204    pub applied_at_tick: u64,
2205    /// `None` = permanent until overwritten / dispelled.
2206    #[serde(default, skip_serializing_if = "Option::is_none")]
2207    pub expires_at_tick: Option<u64>,
2208}
2209
2210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2211pub struct ItemStack {
2212    pub template_id: String,
2213    pub quantity: u32,
2214    /// Stable instance id — preserved across checkpoint/sync when set.
2215    #[serde(default)]
2216    pub item_instance_id: Option<Uuid>,
2217    /// Per-instance metadata (stat rolls, soul-bind, lock ids, etc.).
2218    #[serde(default)]
2219    pub props: BTreeMap<String, String>,
2220    /// Instance-only status effects (template effects live on the item def).
2221    #[serde(default)]
2222    pub status_bindings: Vec<ItemStatusBinding>,
2223    /// Nested contents when this stack is a container instance.
2224    #[serde(default)]
2225    pub contents: Vec<ItemStack>,
2226    /// From item catalog when sent on wire (display only).
2227    #[serde(default)]
2228    pub display_name: Option<String>,
2229    /// From item catalog when sent on wire (`weapon`, `consumable`, …).
2230    #[serde(default)]
2231    pub category: Option<String>,
2232    /// Per-unit mass in kg from catalog (display / move UX).
2233    #[serde(default)]
2234    pub base_mass: Option<f32>,
2235    /// Per-unit volume from catalog (display / move UX).
2236    #[serde(default)]
2237    pub base_volume: Option<f32>,
2238    /// Container capacity when this stack is a container template.
2239    #[serde(default)]
2240    pub capacity_volume: Option<f32>,
2241    /// Whether the template stacks in inventory (from catalog).
2242    #[serde(default)]
2243    pub stackable: Option<bool>,
2244    /// Can be placed on the ground from inventory (from catalog).
2245    #[serde(default)]
2246    pub world_placeable: Option<bool>,
2247    /// Hired-worker lodging capacity when this template is placed (from catalog).
2248    #[serde(default)]
2249    pub worker_lodging_capacity: Option<u32>,
2250    /// Body slot this template equips into (from catalog; display / Equip UI).
2251    #[serde(default)]
2252    pub equip_slot: Option<BodySlot>,
2253    /// Template baseline armor rating (from catalog).
2254    #[serde(default)]
2255    pub armor_physical: Option<f32>,
2256    /// Template resists damage_type → value (from catalog).
2257    #[serde(default)]
2258    pub resists: Vec<(String, f32)>,
2259    /// Weapon hand occupancy when category is weapon (`1` or `2`).
2260    #[serde(default)]
2261    pub hand_slots: Option<u8>,
2262}
2263
2264impl ItemStack {
2265    pub fn simple(template_id: impl Into<String>, quantity: u32) -> Self {
2266        Self {
2267            template_id: template_id.into(),
2268            quantity,
2269            ..Default::default()
2270        }
2271    }
2272}
2273
2274impl Default for ItemStack {
2275    fn default() -> Self {
2276        Self {
2277            template_id: String::new(),
2278            quantity: 0,
2279            item_instance_id: None,
2280            props: BTreeMap::new(),
2281            status_bindings: Vec::new(),
2282            contents: Vec::new(),
2283            display_name: None,
2284            category: None,
2285            base_mass: None,
2286            base_volume: None,
2287            capacity_volume: None,
2288            stackable: None,
2289            world_placeable: None,
2290            worker_lodging_capacity: None,
2291            equip_slot: None,
2292            armor_physical: None,
2293            resists: Vec::new(),
2294            hand_slots: None,
2295        }
2296    }
2297}
2298
2299/// Carry encumbrance band (`plans/08` §3.2).
2300#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2301#[serde(rename_all = "snake_case")]
2302pub enum EncumbranceState {
2303    #[default]
2304    Light,
2305    Heavy,
2306    Over,
2307}
2308
2309/// Body region a wearable item occupies — at most one item equipped per slot.
2310/// Armor, cloak, jewelry, and carriers (`Back` backpack, `Waist` belt). Hands
2311/// (mainhand / offhand) are separate combat sockets — not `BodySlot`.
2312#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)]
2313#[serde(rename_all = "snake_case")]
2314pub enum BodySlot {
2315    Head,
2316    /// Chest / torso armor. Serde alias `body` for older YAML / saves.
2317    #[serde(alias = "body")]
2318    Chest,
2319    /// Gauntlets / bracers / sleeves. Serde alias `arms` for older YAML / saves.
2320    #[serde(alias = "arms")]
2321    Forearms,
2322    Legs,
2323    Feet,
2324    Cloak,
2325    Back,
2326    Waist,
2327    Earrings,
2328    Necklace,
2329    Eyeglasses,
2330    /// Explicit rename: plain `snake_case` would be `ring_left1`.
2331    #[serde(rename = "ring_left_1", alias = "ring_left1")]
2332    RingLeft1,
2333    #[serde(rename = "ring_left_2", alias = "ring_left2")]
2334    RingLeft2,
2335    #[serde(rename = "ring_right_1", alias = "ring_right1")]
2336    RingRight1,
2337    #[serde(rename = "ring_right_2", alias = "ring_right2")]
2338    RingRight2,
2339}
2340
2341impl BodySlot {
2342    /// All worn slots in paperdoll / catalog order.
2343    pub const ALL: [BodySlot; 15] = [
2344        BodySlot::Head,
2345        BodySlot::Chest,
2346        BodySlot::Forearms,
2347        BodySlot::Legs,
2348        BodySlot::Feet,
2349        BodySlot::Cloak,
2350        BodySlot::Back,
2351        BodySlot::Waist,
2352        BodySlot::Earrings,
2353        BodySlot::Necklace,
2354        BodySlot::Eyeglasses,
2355        BodySlot::RingLeft1,
2356        BodySlot::RingLeft2,
2357        BodySlot::RingRight1,
2358        BodySlot::RingRight2,
2359    ];
2360
2361    pub fn as_str(self) -> &'static str {
2362        match self {
2363            BodySlot::Head => "head",
2364            BodySlot::Chest => "chest",
2365            BodySlot::Forearms => "forearms",
2366            BodySlot::Legs => "legs",
2367            BodySlot::Feet => "feet",
2368            BodySlot::Cloak => "cloak",
2369            BodySlot::Back => "back",
2370            BodySlot::Waist => "waist",
2371            BodySlot::Earrings => "earrings",
2372            BodySlot::Necklace => "necklace",
2373            BodySlot::Eyeglasses => "eyeglasses",
2374            BodySlot::RingLeft1 => "ring_left_1",
2375            BodySlot::RingLeft2 => "ring_left_2",
2376            BodySlot::RingRight1 => "ring_right_1",
2377            BodySlot::RingRight2 => "ring_right_2",
2378        }
2379    }
2380}
2381
2382/// Where an item lives for `MoveItem` / open-container UX.
2383#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2384#[serde(rename_all = "snake_case")]
2385pub enum InventoryLocation {
2386    /// Loose on-person inventory (not inside a worn/placed container).
2387    Root,
2388    /// Inside a worn item (backpack contents, or a pouch clipped onto a worn belt).
2389    Worn { slot: BodySlot },
2390    /// Inside a world-placed container.
2391    Placed { container_id: String },
2392    /// Virtual key ring — only `container_key` items; zero carry mass; persists with combat profile.
2393    Keychain,
2394    /// Virtual whisper pouch — only `whisper_stone` items; zero carry mass.
2395    WhisperPouch,
2396}
2397
2398/// AOI view of a placeable chest on the map.
2399#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2400pub struct PlacedContainerView {
2401    pub id: String,
2402    pub template_id: String,
2403    pub display_name: String,
2404    pub x: f32,
2405    pub y: f32,
2406    pub z: f32,
2407    pub locked: bool,
2408    /// Observer can open (unlocked, or holds matching key).
2409    #[serde(default)]
2410    pub accessible: bool,
2411    #[serde(default)]
2412    pub owner_character_id: Option<Uuid>,
2413    /// Nested contents when `accessible` (empty when locked without key).
2414    #[serde(default)]
2415    pub contents: Vec<ItemStack>,
2416    /// Lock id for matching `container_key` props (`opens_lock_id`).
2417    #[serde(default)]
2418    pub lock_id: Option<String>,
2419    /// Internal storage capacity (liters) from item catalog.
2420    #[serde(default)]
2421    pub capacity_volume: Option<f32>,
2422    /// Container instance id (for MoveItem parent targeting).
2423    #[serde(default)]
2424    pub item_instance_id: Option<Uuid>,
2425    /// Optional gfx tile id from the item template.
2426    #[serde(default)]
2427    pub tile_id: Option<String>,
2428    /// Baked paperdoll skin when the item template sets `paperdoll_ref`.
2429    #[serde(default)]
2430    pub paperdoll_ref: Option<String>,
2431    /// Hired-worker slots when this is placed lodging (`category: lodging`).
2432    #[serde(default)]
2433    pub worker_lodging_capacity: Option<u32>,
2434}
2435
2436#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2437pub struct BlueprintIngredientView {
2438    pub template_id: String,
2439    pub quantity: u32,
2440    /// Always serialize — `true` is not bool::default() so postcard keeps it; explicit for clarity.
2441    pub consumed: bool,
2442}
2443
2444#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2445pub struct ToolRequirementView {
2446    pub item: String,
2447    /// Always serialize — postcard omits `false` by default, which breaks roundtrip without explicit value.
2448    pub consumed: bool,
2449}
2450
2451#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2452pub struct SkillRequirementView {
2453    pub skill: String,
2454    pub level: u32,
2455}
2456
2457#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2458pub struct BlueprintView {
2459    pub id: String,
2460    pub label: String,
2461    pub output: String,
2462    pub output_qty: u32,
2463    pub craft_ticks: u32,
2464    pub inputs: Vec<BlueprintIngredientView>,
2465    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
2466    #[serde(default)]
2467    pub station: Option<String>,
2468    #[serde(default)]
2469    pub category: Option<String>,
2470    #[serde(default)]
2471    pub required_tools: Vec<ToolRequirementView>,
2472    #[serde(default)]
2473    pub skill: Option<SkillRequirementView>,
2474    #[serde(default)]
2475    pub failure_chance: f32,
2476    /// Copper cost for the employer to teach this recipe to a hired worker.
2477    #[serde(default)]
2478    pub worker_train_copper: u64,
2479}
2480
2481/// Terrain overlay from segment YAML (`terrain_zones`).
2482#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2483#[serde(rename_all = "snake_case")]
2484pub enum TerrainKindView {
2485    #[default]
2486    Grass,
2487    Dirt,
2488    Tilled,
2489    Desert,
2490    Hill,
2491    Bog,
2492    ShallowWater,
2493    DeepWater,
2494    Trail,
2495    Road,
2496    Rock,
2497}
2498
2499#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2500pub struct TerrainZoneView {
2501    pub id: String,
2502    pub x0: f32,
2503    pub y0: f32,
2504    pub x1: f32,
2505    pub y1: f32,
2506    #[serde(default)]
2507    pub kind: TerrainKindView,
2508    /// Ground elevation at this zone (m).
2509    #[serde(default)]
2510    pub elevation: f32,
2511    /// Optional map glyph override (single character); falls back to terrain kind catalog.
2512    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
2513    #[serde(default)]
2514    pub glyph: Option<String>,
2515    /// Optional color (`#RRGGBB` or ratatui name); falls back to kind / elevation tint.
2516    #[serde(default)]
2517    pub color: Option<String>,
2518    /// Optional gfx tile id (`terrain.grass`, …) — presentation only.
2519    #[serde(default)]
2520    pub tile_id: Option<String>,
2521    /// Overlap priority — higher wins (`segment.terrain_zones`).
2522    #[serde(default)]
2523    pub z_order: i32,
2524}
2525
2526/// Walkable platform at a fixed z (`segment.z_platforms`).
2527#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2528pub struct ZPlatformView {
2529    pub id: String,
2530    pub z: f32,
2531    pub x0: f32,
2532    pub y0: f32,
2533    pub x1: f32,
2534    pub y1: f32,
2535}
2536
2537/// Stairs / ramp linking two z bands (`segment.z_transitions`).
2538#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2539pub struct ZTransitionView {
2540    pub id: String,
2541    pub z_from: f32,
2542    pub z_to: f32,
2543    pub x0: f32,
2544    pub y0: f32,
2545    pub x1: f32,
2546    pub y1: f32,
2547}
2548
2549#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2550pub struct BuildingView {
2551    pub id: String,
2552    pub label: String,
2553    pub x: f32,
2554    pub y: f32,
2555    pub width_m: f32,
2556    pub depth_m: f32,
2557    #[serde(default)]
2558    pub interior_blueprint: Option<String>,
2559    #[serde(default)]
2560    pub tags: Vec<String>,
2561}
2562
2563#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2564pub struct DoorView {
2565    pub id: String,
2566    pub building_id: String,
2567    pub x: f32,
2568    pub y: f32,
2569    #[serde(default)]
2570    pub open: bool,
2571    #[serde(default)]
2572    pub portal: Option<String>,
2573}
2574
2575#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2576pub struct InteriorRoomView {
2577    pub id: String,
2578    pub label: String,
2579    pub floor: i32,
2580    pub x0: f32,
2581    pub y0: f32,
2582    pub x1: f32,
2583    pub y1: f32,
2584    #[serde(default)]
2585    pub floor_color: Option<String>,
2586    #[serde(default)]
2587    pub floor_glyph: Option<String>,
2588}
2589
2590#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2591pub struct InteriorDoorView {
2592    pub id: String,
2593    pub room_a: String,
2594    pub room_b: String,
2595    pub x: f32,
2596    pub y: f32,
2597    pub kind: String,
2598}
2599
2600#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2601pub struct InteriorMapView {
2602    pub building_id: String,
2603    pub blueprint_id: String,
2604    pub background_color: String,
2605    #[serde(default)]
2606    pub default_floor_color: Option<String>,
2607    #[serde(default = "default_floor_height_view")]
2608    pub floor_height_m: f32,
2609    pub rooms: Vec<InteriorRoomView>,
2610    #[serde(default)]
2611    pub room_doors: Vec<InteriorDoorView>,
2612}
2613
2614fn default_floor_height_view() -> f32 {
2615    3.0
2616}
2617
2618#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2619pub struct NpcView {
2620    pub id: String,
2621    pub label: String,
2622    pub role: String,
2623    pub x: f32,
2624    pub y: f32,
2625    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
2626    #[serde(default)]
2627    pub building_id: Option<String>,
2628    /// Authoritative sim entity for wildlife / combat targets.
2629    #[serde(default)]
2630    pub entity_id: Option<EntityId>,
2631    #[serde(default)]
2632    pub life_state: Option<LifeState>,
2633    #[serde(default)]
2634    pub hp_pct: Option<f32>,
2635    /// True when this NPC has buy/sell/teach offers (`npc_has_market`).
2636    #[serde(default)]
2637    pub can_trade: bool,
2638    /// Gfx sprite sheet id (`assets/gfx/sprites/`). Client falls back to `npc.{id}` / `npc.{role}`.
2639    #[serde(default)]
2640    pub tile_id: Option<String>,
2641    /// Wildlife FSM state (`idle`, `chase`, `combat`, …) when behavior-driven (debug).
2642    #[serde(default)]
2643    pub behavior_state: Option<String>,
2644    /// Canonical gfx presentation key (`combat`, `pursue`, `walking`, `talking`, …).
2645    #[serde(default)]
2646    pub presentation_state: Option<String>,
2647    /// Resolved gfx sprite mode for `tile_id` (server-computed).
2648    #[serde(default)]
2649    pub sprite_mode: Option<String>,
2650    /// Paperdoll skin id (`assets/paperdoll/skins/`). Client prefers this over `tile_id` when baked.
2651    #[serde(default)]
2652    pub paperdoll_ref: Option<String>,
2653}
2654
2655#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2656pub struct UseResult {
2657    pub template_id: String,
2658    pub hunger_restored: f32,
2659    pub thirst_restored: f32,
2660    #[serde(default)]
2661    pub health_restored: f32,
2662    #[serde(default)]
2663    pub mana_restored: f32,
2664    #[serde(default)]
2665    pub cleared_dot_ids: Vec<String>,
2666}
2667
2668#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2669pub struct CraftResult {
2670    pub blueprint_id: String,
2671    pub outputs: Vec<ItemStack>,
2672    pub consumed: Vec<ItemStack>,
2673    /// 1-based index within the submitted batch (1 when not batching).
2674    #[serde(default = "default_one")]
2675    pub batch_index: u32,
2676    /// Total crafts requested in this batch (1 when not batching).
2677    #[serde(default = "default_one")]
2678    pub batch_total: u32,
2679}
2680
2681fn default_one() -> u32 {
2682    1
2683}
2684
2685#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2686pub struct DeathNotice {
2687    pub entity_id: EntityId,
2688    pub respawn_x: f32,
2689    pub respawn_y: f32,
2690    pub message: String,
2691}
2692
2693#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2694pub struct InteractionNotice {
2695    pub target_id: String,
2696    pub message: String,
2697    #[serde(default)]
2698    pub coins_delta: i32,
2699    #[serde(default)]
2700    pub inventory_delta: Vec<ItemStack>,
2701}
2702
2703#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2704#[serde(rename_all = "snake_case")]
2705pub enum NpcTalkTrustFlag {
2706    Stranger,
2707    Acquainted,
2708    Trusted,
2709}
2710
2711#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
2712#[serde(rename_all = "snake_case")]
2713pub enum NpcTalkDepth {
2714    #[default]
2715    Full,
2716    Brief,
2717    Unavailable,
2718}
2719
2720#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2721pub struct NpcTalkOpened {
2722    pub npc_id: String,
2723    pub npc_label: String,
2724    pub greeting: String,
2725    pub trust_flag: NpcTalkTrustFlag,
2726    #[serde(default)]
2727    pub talk_depth: NpcTalkDepth,
2728    #[serde(default = "default_true")]
2729    pub trade_allowed: bool,
2730}
2731
2732fn default_true() -> bool {
2733    true
2734}
2735
2736#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2737pub struct NpcTalkPending {
2738    pub npc_id: String,
2739}
2740
2741#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2742pub struct NpcTalkReply {
2743    pub npc_id: String,
2744    pub line: String,
2745    pub trust_flag: NpcTalkTrustFlag,
2746    #[serde(default)]
2747    pub wind_down: bool,
2748    #[serde(default)]
2749    pub trade_disabled: bool,
2750}
2751
2752#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2753pub struct NpcTalkClosed {
2754    pub npc_id: String,
2755}
2756
2757#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2758pub struct NpcTalkError {
2759    pub npc_id: String,
2760    pub reason: String,
2761}
2762
2763#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2764#[serde(rename_all = "snake_case")]
2765pub enum QuestStatusView {
2766    Available,
2767    Active,
2768    Completed,
2769}
2770
2771#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2772pub struct QuestObjectiveProgress {
2773    pub label: String,
2774    pub current: u32,
2775    pub required: u32,
2776    pub done: bool,
2777}
2778
2779#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2780pub struct QuestLogEntry {
2781    pub quest_id: String,
2782    pub title: String,
2783    pub description: String,
2784    pub status: QuestStatusView,
2785    #[serde(default)]
2786    pub current_step_id: Option<String>,
2787    #[serde(default)]
2788    pub current_step_title: String,
2789    #[serde(default)]
2790    pub objectives: Vec<QuestObjectiveProgress>,
2791    #[serde(default)]
2792    pub is_tracked: bool,
2793    #[serde(default)]
2794    pub can_withdraw: bool,
2795}
2796
2797#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2798pub struct InteractableView {
2799    pub id: String,
2800    pub kind: String,
2801    pub label: String,
2802    pub x: f32,
2803    pub y: f32,
2804    pub z: f32,
2805    #[serde(default)]
2806    pub board_id: Option<String>,
2807}
2808
2809#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2810pub struct QuestOffer {
2811    pub quest_id: String,
2812    pub title: String,
2813    pub description: String,
2814    #[serde(default)]
2815    pub step_count: u32,
2816}
2817
2818#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2819pub struct QuestNotice {
2820    pub quest_id: String,
2821    pub title: String,
2822    pub message: String,
2823}
2824
2825#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2826#[serde(rename_all = "snake_case")]
2827pub enum ShopOfferKind {
2828    Item,
2829    Blueprint,
2830}
2831
2832#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2833pub struct ShopOffer {
2834    pub offer_id: String,
2835    pub kind: ShopOfferKind,
2836    pub label: String,
2837    #[serde(default)]
2838    pub template_id: Option<String>,
2839    #[serde(default)]
2840    pub blueprint_id: Option<String>,
2841    pub price_copper: u32,
2842    #[serde(default)]
2843    pub affordable: bool,
2844    #[serde(default)]
2845    pub already_owned: bool,
2846}
2847
2848#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2849pub struct ShopBuyLine {
2850    pub template_id: String,
2851    pub label: String,
2852    pub quantity: u32,
2853    pub price_copper: u32,
2854}
2855
2856/// Bank teller interaction panel (`plans/08` §8).
2857#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2858pub struct BankPanel {
2859    pub npc_id: String,
2860    pub npc_label: String,
2861    pub bank_balance_copper: u64,
2862    pub on_person_copper: u64,
2863    /// Copper still clearing to other accounts (debited, not yet credited).
2864    #[serde(default)]
2865    pub pending_outgoing_copper: u64,
2866    #[serde(default)]
2867    pub transfer_fee_bps: u32,
2868    #[serde(default)]
2869    pub transfer_clear_ticks: u64,
2870}
2871
2872/// Town storage manager panel (`plans/08` §7).
2873#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2874pub struct StoragePanel {
2875    pub npc_id: String,
2876    pub npc_label: String,
2877    pub building_id: String,
2878    pub building_label: String,
2879    pub used_volume: f32,
2880    pub max_volume: f32,
2881    #[serde(default)]
2882    pub contents: Vec<ItemStack>,
2883    /// Other storage buildings that can receive a ship (id, label, distance_m, fee_copper, travel_ticks).
2884    #[serde(default)]
2885    pub ship_destinations: Vec<StorageShipDest>,
2886}
2887
2888#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2889pub struct StorageShipDest {
2890    pub building_id: String,
2891    pub label: String,
2892    pub distance_m: f32,
2893    pub fee_copper: u64,
2894    pub travel_ticks: u64,
2895}
2896
2897#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2898pub struct ShopCatalog {
2899    pub npc_id: String,
2900    pub npc_label: String,
2901    #[serde(default)]
2902    pub sells: Vec<ShopOffer>,
2903    #[serde(default)]
2904    pub buys: Vec<ShopBuyLine>,
2905}
2906
2907#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2908pub struct HarvestResult {
2909    pub node_id: String,
2910    /// Stack quantity granted (not one-node-one-instance).
2911    pub quantity: u32,
2912    pub item_template: String,
2913    /// Optional DB row id when persisted to control plane.
2914    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
2915    #[serde(default)]
2916    pub item_instance_id: Option<Uuid>,
2917}
2918
2919/// Every on-wire payload is wrapped for versioning and codec uniformity.
2920#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2921pub struct Envelope<T> {
2922    pub protocol_version: u16,
2923    pub payload: T,
2924}
2925
2926impl<T> Envelope<T> {
2927    pub fn new(payload: T) -> Self {
2928        Self {
2929            protocol_version: crate::PROTOCOL_VERSION,
2930            payload,
2931        }
2932    }
2933}
2934
2935/// Session handshake after transport connect.
2936#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2937pub struct Hello {
2938    pub client_name: String,
2939    pub protocol_version: u16,
2940    #[serde(default)]
2941    pub auth: AuthCredential,
2942    /// Required for session auth; embedded in `ApiToken` variant otherwise.
2943    #[serde(default)]
2944    pub character_id: Option<Uuid>,
2945}
2946
2947/// How the client authenticates to the game gateway.
2948/// Uses default serde enum encoding (postcard-compatible; not internally tagged).
2949#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2950#[serde(rename_all = "snake_case")]
2951pub enum AuthCredential {
2952    DevLocal,
2953    Session { token: String },
2954    ApiToken { token: String, character_id: Uuid },
2955}
2956
2957impl Default for AuthCredential {
2958    fn default() -> Self {
2959        Self::DevLocal
2960    }
2961}
2962
2963#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2964pub struct Welcome {
2965    pub session_id: SessionId,
2966    pub entity_id: EntityId,
2967    pub snapshot: Snapshot,
2968}
2969
2970#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2971pub enum ServerMessage {
2972    Welcome(Welcome),
2973    /// Static world layers refreshed (blueprints, catalog, map, terrain) — no client restart.
2974    ContentUpdated(Snapshot),
2975    Tick(TickDelta),
2976    IntentAck {
2977        entity_id: EntityId,
2978        seq: Seq,
2979        tick: Tick,
2980    },
2981    Chat(ChatMessage),
2982    HarvestResult(HarvestResult),
2983    UseResult(UseResult),
2984    CraftResult(CraftResult),
2985    Death(DeathNotice),
2986    Interaction(InteractionNotice),
2987    ShopOpened(ShopCatalog),
2988    NpcTalkOpened(NpcTalkOpened),
2989    NpcTalkPending(NpcTalkPending),
2990    NpcTalkReply(NpcTalkReply),
2991    NpcTalkClosed(NpcTalkClosed),
2992    NpcTalkError(NpcTalkError),
2993    QuestOffer(QuestOffer),
2994    QuestAccepted(QuestNotice),
2995    QuestWithdrawn(QuestNotice),
2996    QuestStepCompleted(QuestNotice),
2997    QuestCompleted(QuestNotice),
2998    /// Bank teller panel opened (`plans/08` §8).
2999    BankOpened(BankPanel),
3000    /// Town storage manager panel opened (`plans/08` §7).
3001    StorageOpened(StoragePanel),
3002    /// Player-to-player trade window opened or refreshed.
3003    TradeOpened(TradePanel),
3004    /// Trade ended (cancel, complete, or peer left range).
3005    TradeClosed {
3006        reason: String,
3007    },
3008}
3009
3010/// Live player-to-player trade escrow view (both sides see the same presented buckets).
3011#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3012pub struct TradePanel {
3013    pub peer_entity_id: EntityId,
3014    pub peer_name: String,
3015    pub my_presented: Vec<ItemStack>,
3016    pub their_presented: Vec<ItemStack>,
3017    pub i_ready: bool,
3018    pub they_ready: bool,
3019    /// Predicted carry mass after accepting their presented items (and losing mine).
3020    pub my_mass_after: f32,
3021    pub my_mass_max: f32,
3022    pub my_encumbrance_after: EncumbranceState,
3023    /// True when completing would put the local player Over.
3024    pub overburden_warning: bool,
3025}
3026
3027#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3028pub enum ClientMessage {
3029    Hello(Hello),
3030    Intent(Intent),
3031    Disconnect,
3032}
3033
3034#[cfg(test)]
3035mod tests {
3036    use super::*;
3037
3038    #[test]
3039    fn pristine_vitals_state_yields_full_pools() {
3040        let attrs = PrimaryAttributes::default();
3041        let vitals = StoredVitalsState::default().apply_to(attrs);
3042        assert!(vitals.health > 0.0);
3043        assert_eq!(vitals.health, vitals.health_max);
3044        assert!((vitals.mana_max - 61.0).abs() < 0.01);
3045    }
3046
3047    #[test]
3048    fn humanize_snake_id_title_cases_parts() {
3049        assert_eq!(humanize_snake_id("heal_touch"), "Heal Touch");
3050        assert_eq!(humanize_snake_id("fireball"), "Fireball");
3051        assert_eq!(humanize_snake_id("cone_frost"), "Cone Frost");
3052    }
3053
3054    #[test]
3055    fn saved_vitals_scale_when_pool_max_increases() {
3056        let mut attrs = PrimaryAttributes::default();
3057        attrs.intelligence = 140;
3058        attrs.wisdom = 140;
3059        let saved = StoredVitalsState {
3060            health: 100.0,
3061            mana: 14.0,
3062            stamina: 100.0,
3063            ..StoredVitalsState::default()
3064        };
3065        let vitals = saved.apply_to(attrs);
3066        assert!(vitals.mana_max > 55.0);
3067        assert!(
3068            (vitals.mana - vitals.mana_max).abs() < 0.01,
3069            "full legacy mana bar migrates to full new bar"
3070        );
3071    }
3072
3073    #[test]
3074    fn empty_vitals_state_is_pristine() {
3075        let pristine = StoredVitalsState {
3076            health: 0.0,
3077            mana: 0.0,
3078            stamina: 0.0,
3079            hunger: 0.0,
3080            thirst: 0.0,
3081            coins: 0,
3082            deaths: 0,
3083            life_state: LifeState::Alive,
3084        };
3085        assert!(pristine.is_pristine());
3086        let vitals = pristine.apply_to(PrimaryAttributes::default());
3087        assert!(vitals.health > 0.0);
3088    }
3089
3090    #[test]
3091    fn stored_vitals_roundtrip_preserves_partial_pools() {
3092        let attrs = PrimaryAttributes::default();
3093        let mut live = PlayerVitals::from_attributes(attrs);
3094        live.health = 25.0;
3095        live.hunger = 77.0;
3096        live.deaths = 2;
3097        let stored = StoredVitalsState::from_live(&live);
3098        let restored = stored.apply_to(attrs);
3099        assert!(
3100            (restored.health - 25.0).abs() < 0.01,
3101            "partial HP below cap stays absolute"
3102        );
3103        assert_eq!(restored.hunger, 77.0);
3104        assert_eq!(restored.deaths, 2);
3105    }
3106
3107    #[test]
3108    fn skill_tiers_start_at_zero() {
3109        let skill = SkillProgress::default();
3110        assert_eq!(skill.level, 0);
3111        assert_eq!(skill.display_tier(), 0);
3112        let trained = SkillProgress {
3113            level: 250,
3114            last_trained_tick: 1,
3115        };
3116        assert_eq!(trained.display_tier(), 2);
3117    }
3118
3119    #[test]
3120    fn quest_server_messages_roundtrip_json() {
3121        use crate::codec::{Codec, PostcardCodec};
3122
3123        let offer = ServerMessage::QuestOffer(QuestOffer {
3124            quest_id: "ada_goblin_hunt".into(),
3125            title: "Goblin Trouble".into(),
3126            description: "Help Ada".into(),
3127            step_count: 3,
3128        });
3129        let notice = ServerMessage::QuestAccepted(QuestNotice {
3130            quest_id: "ada_goblin_hunt".into(),
3131            title: "Goblin Trouble".into(),
3132            message: "Quest accepted".into(),
3133        });
3134        for msg in [offer, notice] {
3135            let bytes = PostcardCodec.encode(&msg).unwrap();
3136            let decoded: ServerMessage = PostcardCodec.decode(&bytes).unwrap();
3137            assert_eq!(decoded, msg);
3138        }
3139    }
3140
3141    #[test]
3142    fn hotbar_consumable_binding_roundtrips() {
3143        let binding = hotbar_consumable_binding("bottle_of_water");
3144        assert_eq!(binding, "item:bottle_of_water");
3145        assert!(hotbar_binding_is_consumable(&binding));
3146        assert_eq!(
3147            hotbar_consumable_template(&binding),
3148            Some("bottle_of_water")
3149        );
3150        assert!(!hotbar_binding_is_consumable("fireball"));
3151        assert_eq!(hotbar_consumable_template("fireball"), None);
3152    }
3153}