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