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