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