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