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