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    /// Recipes this worker already knows (from hire `teaches` + employer teach).
1946    #[serde(default)]
1947    pub known_blueprint_ids: Vec<String>,
1948    /// Overall worker level (1+).
1949    #[serde(default = "default_worker_view_level")]
1950    pub level: u32,
1951    /// Cumulative worker XP.
1952    #[serde(default)]
1953    pub worker_xp: f64,
1954    /// Items the worker currently carries (employer-visible for give/take).
1955    #[serde(default)]
1956    pub inventory: Vec<ItemStack>,
1957}
1958
1959fn default_worker_view_level() -> u32 {
1960    1
1961}
1962
1963/// Server → client AOI-filtered entity updates for one sim tick.
1964#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1965pub struct TickDelta {
1966    pub tick: Tick,
1967    pub entities: Vec<EntityState>,
1968    #[serde(default)]
1969    pub resource_nodes: Vec<ResourceNodeView>,
1970    #[serde(default)]
1971    pub buildings: Vec<BuildingView>,
1972    #[serde(default)]
1973    pub doors: Vec<DoorView>,
1974    #[serde(default)]
1975    pub npcs: Vec<NpcView>,
1976    /// Observer inventory stacks (template → qty).
1977    #[serde(default)]
1978    pub inventory: Vec<ItemStack>,
1979    #[serde(default)]
1980    pub blueprints: Vec<BlueprintView>,
1981    #[serde(default)]
1982    pub world_clock: WorldClock,
1983    #[serde(default)]
1984    pub ground_drops: Vec<GroundDropView>,
1985    #[serde(default)]
1986    pub placed_containers: Vec<PlacedContainerView>,
1987    #[serde(default)]
1988    pub combat: Option<CombatHud>,
1989    #[serde(default)]
1990    pub interior_map: Option<InteriorMapView>,
1991    #[serde(default)]
1992    pub quest_log: Vec<QuestLogEntry>,
1993    #[serde(default)]
1994    pub hired_workers: Vec<HiredWorkerView>,
1995    #[serde(default)]
1996    pub interactables: Vec<InteractableView>,
1997    #[serde(default)]
1998    pub ledger: Option<PlayerLedgerView>,
1999    #[serde(default)]
2000    pub career: Option<PlayerCareerView>,
2001    /// Active combat footprints / hit markers in observer AOI (`plans/39`).
2002    #[serde(default)]
2003    pub combat_fx: Vec<CombatFx>,
2004}
2005#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2006pub struct GroundDropView {
2007    pub id: String,
2008    pub template_id: String,
2009    pub quantity: u32,
2010    pub x: f32,
2011    pub y: f32,
2012    pub z: f32,
2013    /// Optional gfx tile id from the item template.
2014    #[serde(default)]
2015    pub tile_id: Option<String>,
2016    /// Optional paperdoll skin id from the item template (preferred over `tile_id` in gfx).
2017    #[serde(default)]
2018    pub paperdoll_ref: Option<String>,
2019    /// Paperdoll facing yaw in radians (0 = north).
2020    #[serde(default)]
2021    pub yaw: f32,
2022    /// Paperdoll pitch in radians (0 = upright).
2023    #[serde(default)]
2024    pub pitch: f32,
2025    /// Paperdoll roll in radians (0 = upright; tilts in the view plane).
2026    #[serde(default)]
2027    pub roll: f32,
2028    /// Draw scale relative to one map cell (1.0 = cell size).
2029    #[serde(default = "default_draw_scale")]
2030    pub draw_scale: f32,
2031}
2032
2033fn default_draw_scale() -> f32 {
2034    1.0
2035}
2036
2037/// Full state on region enter or reconnect.
2038#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2039pub struct Snapshot {
2040    pub tick: Tick,
2041    pub chunk_rev: u64,
2042    /// Bumped when blueprints, catalog, segment, or settings reload.
2043    #[serde(default)]
2044    pub content_rev: u64,
2045    /// Stable publish revision from `assets/.content-publish.json` (for client asset sync).
2046    #[serde(default)]
2047    pub publish_rev: u64,
2048    pub entities: Vec<EntityState>,
2049    #[serde(default)]
2050    pub resource_nodes: Vec<ResourceNodeView>,
2051    /// Segment play area width in meters (for HUD).
2052    #[serde(default)]
2053    pub world_width_m: f32,
2054    #[serde(default)]
2055    pub world_height_m: f32,
2056    #[serde(default)]
2057    pub buildings: Vec<BuildingView>,
2058    #[serde(default)]
2059    pub doors: Vec<DoorView>,
2060    #[serde(default)]
2061    pub npcs: Vec<NpcView>,
2062    #[serde(default)]
2063    pub inventory: Vec<ItemStack>,
2064    #[serde(default)]
2065    pub blueprints: Vec<BlueprintView>,
2066    #[serde(default)]
2067    pub world_clock: WorldClock,
2068    #[serde(default)]
2069    pub terrain_zones: Vec<TerrainZoneView>,
2070    #[serde(default)]
2071    pub z_platforms: Vec<ZPlatformView>,
2072    #[serde(default)]
2073    pub z_transitions: Vec<ZTransitionView>,
2074    #[serde(default)]
2075    pub ground_drops: Vec<GroundDropView>,
2076    #[serde(default)]
2077    pub placed_containers: Vec<PlacedContainerView>,
2078    #[serde(default)]
2079    pub combat: Option<CombatHud>,
2080    #[serde(default)]
2081    pub interior_map: Option<InteriorMapView>,
2082    #[serde(default)]
2083    pub quest_log: Vec<QuestLogEntry>,
2084    #[serde(default)]
2085    pub hired_workers: Vec<HiredWorkerView>,
2086    #[serde(default)]
2087    pub interactables: Vec<InteractableView>,
2088    #[serde(default)]
2089    pub ledger: Option<PlayerLedgerView>,
2090    #[serde(default)]
2091    pub career: Option<PlayerCareerView>,
2092    /// Active combat footprints / hit markers (`plans/39`).
2093    #[serde(default)]
2094    pub combat_fx: Vec<CombatFx>,
2095}
2096
2097/// Harvestable node visible to clients.
2098#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2099pub struct ResourceNodeView {
2100    pub id: String,
2101    pub label: String,
2102    pub x: f32,
2103    pub y: f32,
2104    pub z: f32,
2105    pub item_template: String,
2106    #[serde(default = "default_node_state")]
2107    pub state: ResourceNodeState,
2108    /// When true, players cannot walk through this node while available/harvesting.
2109    #[serde(default = "default_blocking_view")]
2110    pub blocking: bool,
2111    /// Collision radius for pathfinding (meters).
2112    #[serde(default = "default_blocking_radius_view")]
2113    pub blocking_radius_m: f32,
2114    /// Optional gfx tile id (`resource.oak_log`, …).
2115    #[serde(default)]
2116    pub tile_id: Option<String>,
2117    /// Optional paperdoll skin id from the item template (preferred over `tile_id` in gfx).
2118    #[serde(default)]
2119    pub paperdoll_ref: Option<String>,
2120    /// Paperdoll facing yaw in radians (0 = north). From map placement.
2121    #[serde(default)]
2122    pub yaw: f32,
2123    /// Paperdoll pitch in radians (0 = upright). From map placement.
2124    #[serde(default)]
2125    pub pitch: f32,
2126    /// Paperdoll roll in radians (0 = upright). From map placement.
2127    #[serde(default)]
2128    pub roll: f32,
2129    /// Draw scale relative to one map cell (1.0 = cell size).
2130    #[serde(default = "default_draw_scale")]
2131    pub draw_scale: f32,
2132    /// Resolved gfx sprite mode for `tile_id` (server-computed).
2133    #[serde(default)]
2134    pub sprite_mode: Option<String>,
2135    /// Canonical presentation key (`available`, `harvesting`, `depleted`).
2136    #[serde(default)]
2137    pub presentation_state: Option<String>,
2138}
2139
2140fn default_blocking_radius_view() -> f32 {
2141    0.8
2142}
2143
2144fn default_blocking_view() -> bool {
2145    true
2146}
2147
2148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2149#[serde(rename_all = "snake_case")]
2150pub enum ResourceNodeState {
2151    Available,
2152    Harvesting,
2153    Cooldown,
2154}
2155
2156fn default_node_state() -> ResourceNodeState {
2157    ResourceNodeState::Available
2158}
2159
2160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2161#[serde(rename_all = "snake_case")]
2162pub enum ItemStatusBindingMode {
2163    OnHit,
2164    WhileEquipped,
2165}
2166
2167impl Default for ItemStatusBindingMode {
2168    fn default() -> Self {
2169        Self::OnHit
2170    }
2171}
2172
2173/// Per-instance status effect binding (unique gear grants / enchantments).
2174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2175pub struct ItemStatusBinding {
2176    pub effect_id: String,
2177    #[serde(default)]
2178    pub mode: ItemStatusBindingMode,
2179    /// Grant template id, `"loot"`, later `"altar"`, etc.
2180    #[serde(default)]
2181    pub source: String,
2182    #[serde(default)]
2183    pub applied_at_tick: u64,
2184    /// `None` = permanent until overwritten / dispelled.
2185    #[serde(default, skip_serializing_if = "Option::is_none")]
2186    pub expires_at_tick: Option<u64>,
2187}
2188
2189#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2190pub struct ItemStack {
2191    pub template_id: String,
2192    pub quantity: u32,
2193    /// Stable instance id — preserved across checkpoint/sync when set.
2194    #[serde(default)]
2195    pub item_instance_id: Option<Uuid>,
2196    /// Per-instance metadata (stat rolls, soul-bind, lock ids, etc.).
2197    #[serde(default)]
2198    pub props: BTreeMap<String, String>,
2199    /// Instance-only status effects (template effects live on the item def).
2200    #[serde(default)]
2201    pub status_bindings: Vec<ItemStatusBinding>,
2202    /// Nested contents when this stack is a container instance.
2203    #[serde(default)]
2204    pub contents: Vec<ItemStack>,
2205    /// From item catalog when sent on wire (display only).
2206    #[serde(default)]
2207    pub display_name: Option<String>,
2208    /// From item catalog when sent on wire (`weapon`, `consumable`, …).
2209    #[serde(default)]
2210    pub category: Option<String>,
2211    /// Per-unit mass in kg from catalog (display / move UX).
2212    #[serde(default)]
2213    pub base_mass: Option<f32>,
2214    /// Per-unit volume from catalog (display / move UX).
2215    #[serde(default)]
2216    pub base_volume: Option<f32>,
2217    /// Container capacity when this stack is a container template.
2218    #[serde(default)]
2219    pub capacity_volume: Option<f32>,
2220    /// Whether the template stacks in inventory (from catalog).
2221    #[serde(default)]
2222    pub stackable: Option<bool>,
2223    /// Can be placed on the ground from inventory (from catalog).
2224    #[serde(default)]
2225    pub world_placeable: Option<bool>,
2226    /// Hired-worker lodging capacity when this template is placed (from catalog).
2227    #[serde(default)]
2228    pub worker_lodging_capacity: Option<u32>,
2229    /// Body slot this template equips into (from catalog; display / Equip UI).
2230    #[serde(default)]
2231    pub equip_slot: Option<BodySlot>,
2232    /// Template baseline armor rating (from catalog).
2233    #[serde(default)]
2234    pub armor_physical: Option<f32>,
2235    /// Template resists damage_type → value (from catalog).
2236    #[serde(default)]
2237    pub resists: Vec<(String, f32)>,
2238    /// Weapon hand occupancy when category is weapon (`1` or `2`).
2239    #[serde(default)]
2240    pub hand_slots: Option<u8>,
2241}
2242
2243impl ItemStack {
2244    pub fn simple(template_id: impl Into<String>, quantity: u32) -> Self {
2245        Self {
2246            template_id: template_id.into(),
2247            quantity,
2248            ..Default::default()
2249        }
2250    }
2251}
2252
2253impl Default for ItemStack {
2254    fn default() -> Self {
2255        Self {
2256            template_id: String::new(),
2257            quantity: 0,
2258            item_instance_id: None,
2259            props: BTreeMap::new(),
2260            status_bindings: Vec::new(),
2261            contents: Vec::new(),
2262            display_name: None,
2263            category: None,
2264            base_mass: None,
2265            base_volume: None,
2266            capacity_volume: None,
2267            stackable: None,
2268            world_placeable: None,
2269            worker_lodging_capacity: None,
2270            equip_slot: None,
2271            armor_physical: None,
2272            resists: Vec::new(),
2273            hand_slots: None,
2274        }
2275    }
2276}
2277
2278/// Carry encumbrance band (`plans/08` §3.2).
2279#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2280#[serde(rename_all = "snake_case")]
2281pub enum EncumbranceState {
2282    #[default]
2283    Light,
2284    Heavy,
2285    Over,
2286}
2287
2288/// Body region a wearable item occupies — at most one item equipped per slot.
2289/// Armor, cloak, jewelry, and carriers (`Back` backpack, `Waist` belt). Hands
2290/// (mainhand / offhand) are separate combat sockets — not `BodySlot`.
2291#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)]
2292#[serde(rename_all = "snake_case")]
2293pub enum BodySlot {
2294    Head,
2295    /// Chest / torso armor. Serde alias `body` for older YAML / saves.
2296    #[serde(alias = "body")]
2297    Chest,
2298    /// Gauntlets / bracers / sleeves. Serde alias `arms` for older YAML / saves.
2299    #[serde(alias = "arms")]
2300    Forearms,
2301    Legs,
2302    Feet,
2303    Cloak,
2304    Back,
2305    Waist,
2306    Earrings,
2307    Necklace,
2308    Eyeglasses,
2309    /// Explicit rename: plain `snake_case` would be `ring_left1`.
2310    #[serde(rename = "ring_left_1", alias = "ring_left1")]
2311    RingLeft1,
2312    #[serde(rename = "ring_left_2", alias = "ring_left2")]
2313    RingLeft2,
2314    #[serde(rename = "ring_right_1", alias = "ring_right1")]
2315    RingRight1,
2316    #[serde(rename = "ring_right_2", alias = "ring_right2")]
2317    RingRight2,
2318}
2319
2320impl BodySlot {
2321    /// All worn slots in paperdoll / catalog order.
2322    pub const ALL: [BodySlot; 15] = [
2323        BodySlot::Head,
2324        BodySlot::Chest,
2325        BodySlot::Forearms,
2326        BodySlot::Legs,
2327        BodySlot::Feet,
2328        BodySlot::Cloak,
2329        BodySlot::Back,
2330        BodySlot::Waist,
2331        BodySlot::Earrings,
2332        BodySlot::Necklace,
2333        BodySlot::Eyeglasses,
2334        BodySlot::RingLeft1,
2335        BodySlot::RingLeft2,
2336        BodySlot::RingRight1,
2337        BodySlot::RingRight2,
2338    ];
2339
2340    pub fn as_str(self) -> &'static str {
2341        match self {
2342            BodySlot::Head => "head",
2343            BodySlot::Chest => "chest",
2344            BodySlot::Forearms => "forearms",
2345            BodySlot::Legs => "legs",
2346            BodySlot::Feet => "feet",
2347            BodySlot::Cloak => "cloak",
2348            BodySlot::Back => "back",
2349            BodySlot::Waist => "waist",
2350            BodySlot::Earrings => "earrings",
2351            BodySlot::Necklace => "necklace",
2352            BodySlot::Eyeglasses => "eyeglasses",
2353            BodySlot::RingLeft1 => "ring_left_1",
2354            BodySlot::RingLeft2 => "ring_left_2",
2355            BodySlot::RingRight1 => "ring_right_1",
2356            BodySlot::RingRight2 => "ring_right_2",
2357        }
2358    }
2359}
2360
2361/// Where an item lives for `MoveItem` / open-container UX.
2362#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2363#[serde(rename_all = "snake_case")]
2364pub enum InventoryLocation {
2365    /// Loose on-person inventory (not inside a worn/placed container).
2366    Root,
2367    /// Inside a worn item (backpack contents, or a pouch clipped onto a worn belt).
2368    Worn { slot: BodySlot },
2369    /// Inside a world-placed container.
2370    Placed { container_id: String },
2371    /// Virtual key ring — only `container_key` items; zero carry mass; persists with combat profile.
2372    Keychain,
2373    /// Virtual whisper pouch — only `whisper_stone` items; zero carry mass.
2374    WhisperPouch,
2375}
2376
2377/// AOI view of a placeable chest on the map.
2378#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2379pub struct PlacedContainerView {
2380    pub id: String,
2381    pub template_id: String,
2382    pub display_name: String,
2383    pub x: f32,
2384    pub y: f32,
2385    pub z: f32,
2386    pub locked: bool,
2387    /// Observer can open (unlocked, or holds matching key).
2388    #[serde(default)]
2389    pub accessible: bool,
2390    #[serde(default)]
2391    pub owner_character_id: Option<Uuid>,
2392    /// Nested contents when `accessible` (empty when locked without key).
2393    #[serde(default)]
2394    pub contents: Vec<ItemStack>,
2395    /// Lock id for matching `container_key` props (`opens_lock_id`).
2396    #[serde(default)]
2397    pub lock_id: Option<String>,
2398    /// Internal storage capacity (liters) from item catalog.
2399    #[serde(default)]
2400    pub capacity_volume: Option<f32>,
2401    /// Container instance id (for MoveItem parent targeting).
2402    #[serde(default)]
2403    pub item_instance_id: Option<Uuid>,
2404    /// Optional gfx tile id from the item template.
2405    #[serde(default)]
2406    pub tile_id: Option<String>,
2407    /// Baked paperdoll skin when the item template sets `paperdoll_ref`.
2408    #[serde(default)]
2409    pub paperdoll_ref: Option<String>,
2410    /// Hired-worker slots when this is placed lodging (`category: lodging`).
2411    #[serde(default)]
2412    pub worker_lodging_capacity: Option<u32>,
2413}
2414
2415#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2416pub struct BlueprintIngredientView {
2417    pub template_id: String,
2418    pub quantity: u32,
2419    /// Always serialize — `true` is not bool::default() so postcard keeps it; explicit for clarity.
2420    pub consumed: bool,
2421}
2422
2423#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2424pub struct ToolRequirementView {
2425    pub item: String,
2426    /// Always serialize — postcard omits `false` by default, which breaks roundtrip without explicit value.
2427    pub consumed: bool,
2428}
2429
2430#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2431pub struct SkillRequirementView {
2432    pub skill: String,
2433    pub level: u32,
2434}
2435
2436#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2437pub struct BlueprintView {
2438    pub id: String,
2439    pub label: String,
2440    pub output: String,
2441    pub output_qty: u32,
2442    pub craft_ticks: u32,
2443    pub inputs: Vec<BlueprintIngredientView>,
2444    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
2445    #[serde(default)]
2446    pub station: Option<String>,
2447    #[serde(default)]
2448    pub category: Option<String>,
2449    #[serde(default)]
2450    pub required_tools: Vec<ToolRequirementView>,
2451    #[serde(default)]
2452    pub skill: Option<SkillRequirementView>,
2453    #[serde(default)]
2454    pub failure_chance: f32,
2455    /// Copper cost for the employer to teach this recipe to a hired worker.
2456    #[serde(default)]
2457    pub worker_train_copper: u64,
2458}
2459
2460/// Terrain overlay from segment YAML (`terrain_zones`).
2461#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2462#[serde(rename_all = "snake_case")]
2463pub enum TerrainKindView {
2464    #[default]
2465    Grass,
2466    Dirt,
2467    Tilled,
2468    Desert,
2469    Hill,
2470    Bog,
2471    ShallowWater,
2472    DeepWater,
2473    Trail,
2474    Road,
2475    Rock,
2476}
2477
2478#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2479pub struct TerrainZoneView {
2480    pub id: String,
2481    pub x0: f32,
2482    pub y0: f32,
2483    pub x1: f32,
2484    pub y1: f32,
2485    #[serde(default)]
2486    pub kind: TerrainKindView,
2487    /// Ground elevation at this zone (m).
2488    #[serde(default)]
2489    pub elevation: f32,
2490    /// Optional map glyph override (single character); falls back to terrain kind catalog.
2491    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
2492    #[serde(default)]
2493    pub glyph: Option<String>,
2494    /// Optional color (`#RRGGBB` or ratatui name); falls back to kind / elevation tint.
2495    #[serde(default)]
2496    pub color: Option<String>,
2497    /// Optional gfx tile id (`terrain.grass`, …) — presentation only.
2498    #[serde(default)]
2499    pub tile_id: Option<String>,
2500    /// Overlap priority — higher wins (`segment.terrain_zones`).
2501    #[serde(default)]
2502    pub z_order: i32,
2503}
2504
2505/// Walkable platform at a fixed z (`segment.z_platforms`).
2506#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2507pub struct ZPlatformView {
2508    pub id: String,
2509    pub z: f32,
2510    pub x0: f32,
2511    pub y0: f32,
2512    pub x1: f32,
2513    pub y1: f32,
2514}
2515
2516/// Stairs / ramp linking two z bands (`segment.z_transitions`).
2517#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2518pub struct ZTransitionView {
2519    pub id: String,
2520    pub z_from: f32,
2521    pub z_to: f32,
2522    pub x0: f32,
2523    pub y0: f32,
2524    pub x1: f32,
2525    pub y1: f32,
2526}
2527
2528#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2529pub struct BuildingView {
2530    pub id: String,
2531    pub label: String,
2532    pub x: f32,
2533    pub y: f32,
2534    pub width_m: f32,
2535    pub depth_m: f32,
2536    #[serde(default)]
2537    pub interior_blueprint: Option<String>,
2538    #[serde(default)]
2539    pub tags: Vec<String>,
2540}
2541
2542#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2543pub struct DoorView {
2544    pub id: String,
2545    pub building_id: String,
2546    pub x: f32,
2547    pub y: f32,
2548    #[serde(default)]
2549    pub open: bool,
2550    #[serde(default)]
2551    pub portal: Option<String>,
2552}
2553
2554#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2555pub struct InteriorRoomView {
2556    pub id: String,
2557    pub label: String,
2558    pub floor: i32,
2559    pub x0: f32,
2560    pub y0: f32,
2561    pub x1: f32,
2562    pub y1: f32,
2563    #[serde(default)]
2564    pub floor_color: Option<String>,
2565    #[serde(default)]
2566    pub floor_glyph: Option<String>,
2567}
2568
2569#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2570pub struct InteriorDoorView {
2571    pub id: String,
2572    pub room_a: String,
2573    pub room_b: String,
2574    pub x: f32,
2575    pub y: f32,
2576    pub kind: String,
2577}
2578
2579#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2580pub struct InteriorMapView {
2581    pub building_id: String,
2582    pub blueprint_id: String,
2583    pub background_color: String,
2584    #[serde(default)]
2585    pub default_floor_color: Option<String>,
2586    #[serde(default = "default_floor_height_view")]
2587    pub floor_height_m: f32,
2588    pub rooms: Vec<InteriorRoomView>,
2589    #[serde(default)]
2590    pub room_doors: Vec<InteriorDoorView>,
2591}
2592
2593fn default_floor_height_view() -> f32 {
2594    3.0
2595}
2596
2597#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2598pub struct NpcView {
2599    pub id: String,
2600    pub label: String,
2601    pub role: String,
2602    pub x: f32,
2603    pub y: f32,
2604    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
2605    #[serde(default)]
2606    pub building_id: Option<String>,
2607    /// Authoritative sim entity for wildlife / combat targets.
2608    #[serde(default)]
2609    pub entity_id: Option<EntityId>,
2610    #[serde(default)]
2611    pub life_state: Option<LifeState>,
2612    #[serde(default)]
2613    pub hp_pct: Option<f32>,
2614    /// True when this NPC has buy/sell/teach offers (`npc_has_market`).
2615    #[serde(default)]
2616    pub can_trade: bool,
2617    /// Gfx sprite sheet id (`assets/gfx/sprites/`). Client falls back to `npc.{id}` / `npc.{role}`.
2618    #[serde(default)]
2619    pub tile_id: Option<String>,
2620    /// Wildlife FSM state (`idle`, `chase`, `combat`, …) when behavior-driven (debug).
2621    #[serde(default)]
2622    pub behavior_state: Option<String>,
2623    /// Canonical gfx presentation key (`combat`, `pursue`, `walking`, `talking`, …).
2624    #[serde(default)]
2625    pub presentation_state: Option<String>,
2626    /// Resolved gfx sprite mode for `tile_id` (server-computed).
2627    #[serde(default)]
2628    pub sprite_mode: Option<String>,
2629    /// Paperdoll skin id (`assets/paperdoll/skins/`). Client prefers this over `tile_id` when baked.
2630    #[serde(default)]
2631    pub paperdoll_ref: Option<String>,
2632}
2633
2634#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2635pub struct UseResult {
2636    pub template_id: String,
2637    pub hunger_restored: f32,
2638    pub thirst_restored: f32,
2639    #[serde(default)]
2640    pub health_restored: f32,
2641    #[serde(default)]
2642    pub mana_restored: f32,
2643    #[serde(default)]
2644    pub cleared_dot_ids: Vec<String>,
2645}
2646
2647#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2648pub struct CraftResult {
2649    pub blueprint_id: String,
2650    pub outputs: Vec<ItemStack>,
2651    pub consumed: Vec<ItemStack>,
2652    /// 1-based index within the submitted batch (1 when not batching).
2653    #[serde(default = "default_one")]
2654    pub batch_index: u32,
2655    /// Total crafts requested in this batch (1 when not batching).
2656    #[serde(default = "default_one")]
2657    pub batch_total: u32,
2658}
2659
2660fn default_one() -> u32 {
2661    1
2662}
2663
2664#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2665pub struct DeathNotice {
2666    pub entity_id: EntityId,
2667    pub respawn_x: f32,
2668    pub respawn_y: f32,
2669    pub message: String,
2670}
2671
2672#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2673pub struct InteractionNotice {
2674    pub target_id: String,
2675    pub message: String,
2676    #[serde(default)]
2677    pub coins_delta: i32,
2678    #[serde(default)]
2679    pub inventory_delta: Vec<ItemStack>,
2680}
2681
2682#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2683#[serde(rename_all = "snake_case")]
2684pub enum NpcTalkTrustFlag {
2685    Stranger,
2686    Acquainted,
2687    Trusted,
2688}
2689
2690#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
2691#[serde(rename_all = "snake_case")]
2692pub enum NpcTalkDepth {
2693    #[default]
2694    Full,
2695    Brief,
2696    Unavailable,
2697}
2698
2699#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2700pub struct NpcTalkOpened {
2701    pub npc_id: String,
2702    pub npc_label: String,
2703    pub greeting: String,
2704    pub trust_flag: NpcTalkTrustFlag,
2705    #[serde(default)]
2706    pub talk_depth: NpcTalkDepth,
2707    #[serde(default = "default_true")]
2708    pub trade_allowed: bool,
2709}
2710
2711fn default_true() -> bool {
2712    true
2713}
2714
2715#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2716pub struct NpcTalkPending {
2717    pub npc_id: String,
2718}
2719
2720#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2721pub struct NpcTalkReply {
2722    pub npc_id: String,
2723    pub line: String,
2724    pub trust_flag: NpcTalkTrustFlag,
2725    #[serde(default)]
2726    pub wind_down: bool,
2727    #[serde(default)]
2728    pub trade_disabled: bool,
2729}
2730
2731#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2732pub struct NpcTalkClosed {
2733    pub npc_id: String,
2734}
2735
2736#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2737pub struct NpcTalkError {
2738    pub npc_id: String,
2739    pub reason: String,
2740}
2741
2742#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2743#[serde(rename_all = "snake_case")]
2744pub enum QuestStatusView {
2745    Available,
2746    Active,
2747    Completed,
2748}
2749
2750#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2751pub struct QuestObjectiveProgress {
2752    pub label: String,
2753    pub current: u32,
2754    pub required: u32,
2755    pub done: bool,
2756}
2757
2758#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2759pub struct QuestLogEntry {
2760    pub quest_id: String,
2761    pub title: String,
2762    pub description: String,
2763    pub status: QuestStatusView,
2764    #[serde(default)]
2765    pub current_step_id: Option<String>,
2766    #[serde(default)]
2767    pub current_step_title: String,
2768    #[serde(default)]
2769    pub objectives: Vec<QuestObjectiveProgress>,
2770    #[serde(default)]
2771    pub is_tracked: bool,
2772    #[serde(default)]
2773    pub can_withdraw: bool,
2774}
2775
2776#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2777pub struct InteractableView {
2778    pub id: String,
2779    pub kind: String,
2780    pub label: String,
2781    pub x: f32,
2782    pub y: f32,
2783    pub z: f32,
2784    #[serde(default)]
2785    pub board_id: Option<String>,
2786}
2787
2788#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2789pub struct QuestOffer {
2790    pub quest_id: String,
2791    pub title: String,
2792    pub description: String,
2793    #[serde(default)]
2794    pub step_count: u32,
2795}
2796
2797#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2798pub struct QuestNotice {
2799    pub quest_id: String,
2800    pub title: String,
2801    pub message: String,
2802}
2803
2804#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2805#[serde(rename_all = "snake_case")]
2806pub enum ShopOfferKind {
2807    Item,
2808    Blueprint,
2809}
2810
2811#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2812pub struct ShopOffer {
2813    pub offer_id: String,
2814    pub kind: ShopOfferKind,
2815    pub label: String,
2816    #[serde(default)]
2817    pub template_id: Option<String>,
2818    #[serde(default)]
2819    pub blueprint_id: Option<String>,
2820    pub price_copper: u32,
2821    #[serde(default)]
2822    pub affordable: bool,
2823    #[serde(default)]
2824    pub already_owned: bool,
2825}
2826
2827#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2828pub struct ShopBuyLine {
2829    pub template_id: String,
2830    pub label: String,
2831    pub quantity: u32,
2832    pub price_copper: u32,
2833}
2834
2835/// Bank teller interaction panel (`plans/08` §8).
2836#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2837pub struct BankPanel {
2838    pub npc_id: String,
2839    pub npc_label: String,
2840    pub bank_balance_copper: u64,
2841    pub on_person_copper: u64,
2842    /// Copper still clearing to other accounts (debited, not yet credited).
2843    #[serde(default)]
2844    pub pending_outgoing_copper: u64,
2845    #[serde(default)]
2846    pub transfer_fee_bps: u32,
2847    #[serde(default)]
2848    pub transfer_clear_ticks: u64,
2849}
2850
2851/// Town storage manager panel (`plans/08` §7).
2852#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2853pub struct StoragePanel {
2854    pub npc_id: String,
2855    pub npc_label: String,
2856    pub building_id: String,
2857    pub building_label: String,
2858    pub used_volume: f32,
2859    pub max_volume: f32,
2860    #[serde(default)]
2861    pub contents: Vec<ItemStack>,
2862    /// Other storage buildings that can receive a ship (id, label, distance_m, fee_copper, travel_ticks).
2863    #[serde(default)]
2864    pub ship_destinations: Vec<StorageShipDest>,
2865}
2866
2867#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2868pub struct StorageShipDest {
2869    pub building_id: String,
2870    pub label: String,
2871    pub distance_m: f32,
2872    pub fee_copper: u64,
2873    pub travel_ticks: u64,
2874}
2875
2876#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2877pub struct ShopCatalog {
2878    pub npc_id: String,
2879    pub npc_label: String,
2880    #[serde(default)]
2881    pub sells: Vec<ShopOffer>,
2882    #[serde(default)]
2883    pub buys: Vec<ShopBuyLine>,
2884}
2885
2886#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2887pub struct HarvestResult {
2888    pub node_id: String,
2889    /// Stack quantity granted (not one-node-one-instance).
2890    pub quantity: u32,
2891    pub item_template: String,
2892    /// Optional DB row id when persisted to control plane.
2893    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
2894    #[serde(default)]
2895    pub item_instance_id: Option<Uuid>,
2896}
2897
2898/// Every on-wire payload is wrapped for versioning and codec uniformity.
2899#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2900pub struct Envelope<T> {
2901    pub protocol_version: u16,
2902    pub payload: T,
2903}
2904
2905impl<T> Envelope<T> {
2906    pub fn new(payload: T) -> Self {
2907        Self {
2908            protocol_version: crate::PROTOCOL_VERSION,
2909            payload,
2910        }
2911    }
2912}
2913
2914/// Session handshake after transport connect.
2915#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2916pub struct Hello {
2917    pub client_name: String,
2918    pub protocol_version: u16,
2919    #[serde(default)]
2920    pub auth: AuthCredential,
2921    /// Required for session auth; embedded in `ApiToken` variant otherwise.
2922    #[serde(default)]
2923    pub character_id: Option<Uuid>,
2924}
2925
2926/// How the client authenticates to the game gateway.
2927/// Uses default serde enum encoding (postcard-compatible; not internally tagged).
2928#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2929#[serde(rename_all = "snake_case")]
2930pub enum AuthCredential {
2931    DevLocal,
2932    Session { token: String },
2933    ApiToken { token: String, character_id: Uuid },
2934}
2935
2936impl Default for AuthCredential {
2937    fn default() -> Self {
2938        Self::DevLocal
2939    }
2940}
2941
2942#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2943pub struct Welcome {
2944    pub session_id: SessionId,
2945    pub entity_id: EntityId,
2946    pub snapshot: Snapshot,
2947}
2948
2949#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2950pub enum ServerMessage {
2951    Welcome(Welcome),
2952    /// Static world layers refreshed (blueprints, catalog, map, terrain) — no client restart.
2953    ContentUpdated(Snapshot),
2954    Tick(TickDelta),
2955    IntentAck {
2956        entity_id: EntityId,
2957        seq: Seq,
2958        tick: Tick,
2959    },
2960    Chat(ChatMessage),
2961    HarvestResult(HarvestResult),
2962    UseResult(UseResult),
2963    CraftResult(CraftResult),
2964    Death(DeathNotice),
2965    Interaction(InteractionNotice),
2966    ShopOpened(ShopCatalog),
2967    NpcTalkOpened(NpcTalkOpened),
2968    NpcTalkPending(NpcTalkPending),
2969    NpcTalkReply(NpcTalkReply),
2970    NpcTalkClosed(NpcTalkClosed),
2971    NpcTalkError(NpcTalkError),
2972    QuestOffer(QuestOffer),
2973    QuestAccepted(QuestNotice),
2974    QuestWithdrawn(QuestNotice),
2975    QuestStepCompleted(QuestNotice),
2976    QuestCompleted(QuestNotice),
2977    /// Bank teller panel opened (`plans/08` §8).
2978    BankOpened(BankPanel),
2979    /// Town storage manager panel opened (`plans/08` §7).
2980    StorageOpened(StoragePanel),
2981    /// Player-to-player trade window opened or refreshed.
2982    TradeOpened(TradePanel),
2983    /// Trade ended (cancel, complete, or peer left range).
2984    TradeClosed {
2985        reason: String,
2986    },
2987}
2988
2989/// Live player-to-player trade escrow view (both sides see the same presented buckets).
2990#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2991pub struct TradePanel {
2992    pub peer_entity_id: EntityId,
2993    pub peer_name: String,
2994    pub my_presented: Vec<ItemStack>,
2995    pub their_presented: Vec<ItemStack>,
2996    pub i_ready: bool,
2997    pub they_ready: bool,
2998    /// Predicted carry mass after accepting their presented items (and losing mine).
2999    pub my_mass_after: f32,
3000    pub my_mass_max: f32,
3001    pub my_encumbrance_after: EncumbranceState,
3002    /// True when completing would put the local player Over.
3003    pub overburden_warning: bool,
3004}
3005
3006#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3007pub enum ClientMessage {
3008    Hello(Hello),
3009    Intent(Intent),
3010    Disconnect,
3011}
3012
3013#[cfg(test)]
3014mod tests {
3015    use super::*;
3016
3017    #[test]
3018    fn pristine_vitals_state_yields_full_pools() {
3019        let attrs = PrimaryAttributes::default();
3020        let vitals = StoredVitalsState::default().apply_to(attrs);
3021        assert!(vitals.health > 0.0);
3022        assert_eq!(vitals.health, vitals.health_max);
3023        assert!((vitals.mana_max - 61.0).abs() < 0.01);
3024    }
3025
3026    #[test]
3027    fn saved_vitals_scale_when_pool_max_increases() {
3028        let mut attrs = PrimaryAttributes::default();
3029        attrs.intelligence = 140;
3030        attrs.wisdom = 140;
3031        let saved = StoredVitalsState {
3032            health: 100.0,
3033            mana: 14.0,
3034            stamina: 100.0,
3035            ..StoredVitalsState::default()
3036        };
3037        let vitals = saved.apply_to(attrs);
3038        assert!(vitals.mana_max > 55.0);
3039        assert!(
3040            (vitals.mana - vitals.mana_max).abs() < 0.01,
3041            "full legacy mana bar migrates to full new bar"
3042        );
3043    }
3044
3045    #[test]
3046    fn empty_vitals_state_is_pristine() {
3047        let pristine = StoredVitalsState {
3048            health: 0.0,
3049            mana: 0.0,
3050            stamina: 0.0,
3051            hunger: 0.0,
3052            thirst: 0.0,
3053            coins: 0,
3054            deaths: 0,
3055            life_state: LifeState::Alive,
3056        };
3057        assert!(pristine.is_pristine());
3058        let vitals = pristine.apply_to(PrimaryAttributes::default());
3059        assert!(vitals.health > 0.0);
3060    }
3061
3062    #[test]
3063    fn stored_vitals_roundtrip_preserves_partial_pools() {
3064        let attrs = PrimaryAttributes::default();
3065        let mut live = PlayerVitals::from_attributes(attrs);
3066        live.health = 25.0;
3067        live.hunger = 77.0;
3068        live.deaths = 2;
3069        let stored = StoredVitalsState::from_live(&live);
3070        let restored = stored.apply_to(attrs);
3071        assert!(
3072            (restored.health - 25.0).abs() < 0.01,
3073            "partial HP below cap stays absolute"
3074        );
3075        assert_eq!(restored.hunger, 77.0);
3076        assert_eq!(restored.deaths, 2);
3077    }
3078
3079    #[test]
3080    fn skill_tiers_start_at_zero() {
3081        let skill = SkillProgress::default();
3082        assert_eq!(skill.level, 0);
3083        assert_eq!(skill.display_tier(), 0);
3084        let trained = SkillProgress {
3085            level: 250,
3086            last_trained_tick: 1,
3087        };
3088        assert_eq!(trained.display_tier(), 2);
3089    }
3090
3091    #[test]
3092    fn quest_server_messages_roundtrip_json() {
3093        use crate::codec::{Codec, PostcardCodec};
3094
3095        let offer = ServerMessage::QuestOffer(QuestOffer {
3096            quest_id: "ada_goblin_hunt".into(),
3097            title: "Goblin Trouble".into(),
3098            description: "Help Ada".into(),
3099            step_count: 3,
3100        });
3101        let notice = ServerMessage::QuestAccepted(QuestNotice {
3102            quest_id: "ada_goblin_hunt".into(),
3103            title: "Goblin Trouble".into(),
3104            message: "Quest accepted".into(),
3105        });
3106        for msg in [offer, notice] {
3107            let bytes = PostcardCodec.encode(&msg).unwrap();
3108            let decoded: ServerMessage = PostcardCodec.decode(&bytes).unwrap();
3109            assert_eq!(decoded, msg);
3110        }
3111    }
3112
3113    #[test]
3114    fn hotbar_consumable_binding_roundtrips() {
3115        let binding = hotbar_consumable_binding("bottle_of_water");
3116        assert_eq!(binding, "item:bottle_of_water");
3117        assert!(hotbar_binding_is_consumable(&binding));
3118        assert_eq!(
3119            hotbar_consumable_template(&binding),
3120            Some("bottle_of_water")
3121        );
3122        assert!(!hotbar_binding_is_consumable("fireball"));
3123        assert_eq!(hotbar_consumable_template("fireball"), None);
3124    }
3125}