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
21/// Ground / free-aim cast point (map X/Y, optional elevation).
22#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
23pub struct AimPoint {
24    pub x: f32,
25    pub y: f32,
26    #[serde(default)]
27    pub z: f32,
28}
29
30impl AimPoint {
31    pub fn xy(x: f32, y: f32) -> Self {
32        Self { x, y, z: 0.0 }
33    }
34}
35
36impl WorldCoord {
37    pub fn surface(x: f32, y: f32) -> Self {
38        Self {
39            x,
40            y,
41            z: 0.0,
42            w: 0,
43            t: 0,
44        }
45    }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
49pub struct Velocity2D {
50    pub vx: f32,
51    pub vy: f32,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
55pub struct Transform {
56    pub position: WorldCoord,
57    pub yaw: f32,
58    pub velocity: Velocity2D,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum LifeState {
64    Alive,
65    Dead,
66}
67
68impl Default for LifeState {
69    fn default() -> Self {
70        Self::Alive
71    }
72}
73
74/// Named slice of the in-game day (drives future weather / spawn tables).
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum TimeOfDayPhase {
78    Night,
79    Dawn,
80    Morning,
81    Midday,
82    Afternoon,
83    Evening,
84}
85
86impl TimeOfDayPhase {
87    /// Authorable snake_case ids, in clock order. Keep in sync with content-admin
88    /// `WORLD_CLOCK_PHASES` and `schemas/npc-definition.schema.json`.
89    pub const ALL: [Self; 6] = [
90        Self::Night,
91        Self::Dawn,
92        Self::Morning,
93        Self::Midday,
94        Self::Afternoon,
95        Self::Evening,
96    ];
97
98    pub fn label(self) -> &'static str {
99        match self {
100            Self::Night => "Night",
101            Self::Dawn => "Dawn",
102            Self::Morning => "Morning",
103            Self::Midday => "Midday",
104            Self::Afternoon => "Afternoon",
105            Self::Evening => "Evening",
106        }
107    }
108
109    pub fn snake_name(self) -> &'static str {
110        match self {
111            Self::Night => "night",
112            Self::Dawn => "dawn",
113            Self::Morning => "morning",
114            Self::Midday => "midday",
115            Self::Afternoon => "afternoon",
116            Self::Evening => "evening",
117        }
118    }
119
120    pub fn try_from_name(name: &str) -> Option<Self> {
121        Some(match name.to_ascii_lowercase().as_str() {
122            "night" => Self::Night,
123            "dawn" => Self::Dawn,
124            "morning" => Self::Morning,
125            "midday" | "mid_day" | "noon" => Self::Midday,
126            "afternoon" => Self::Afternoon,
127            "evening" | "dusk" => Self::Evening,
128            _ => return None,
129        })
130    }
131
132    pub fn from_name(name: &str) -> Self {
133        Self::try_from_name(name).unwrap_or(Self::Night)
134    }
135}
136
137/// Lowercase, de-dupe, and drop unknown world-clock phase names.
138pub fn normalize_world_clock_phase_names<I, S>(names: I) -> Vec<String>
139where
140    I: IntoIterator<Item = S>,
141    S: AsRef<str>,
142{
143    let mut out = Vec::new();
144    for name in names {
145        let Some(phase) = TimeOfDayPhase::try_from_name(name.as_ref()) else {
146            continue;
147        };
148        let id = phase.snake_name().to_string();
149        if !out.iter().any(|existing| existing == &id) {
150            out.push(id);
151        }
152    }
153    out
154}
155
156/// Authoritative region clock (one in-game day = configurable real duration).
157#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
158pub struct WorldClock {
159    /// Days elapsed since region start (0-based).
160    pub day: u64,
161    pub hour: u8,
162    pub minute: u8,
163    pub phase: TimeOfDayPhase,
164}
165
166impl Default for WorldClock {
167    fn default() -> Self {
168        Self {
169            day: 0,
170            hour: 8,
171            minute: 0,
172            phase: TimeOfDayPhase::Morning,
173        }
174    }
175}
176
177impl WorldClock {
178    pub fn display_time(self) -> String {
179        format!("{:02}:{:02}", self.hour, self.minute)
180    }
181}
182
183/// Core primary stats — internal scale 1–1000; UI displays `value / 10` (1–100).
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
185pub struct PrimaryAttributes {
186    pub strength: u16,
187    pub dexterity: u16,
188    pub intelligence: u16,
189    pub stamina: u16,
190    pub vitality: u16,
191    pub wisdom: u16,
192    pub charisma: u16,
193}
194
195impl Default for PrimaryAttributes {
196    fn default() -> Self {
197        Self {
198            strength: 150,
199            dexterity: 150,
200            intelligence: 150,
201            stamina: 150,
202            vitality: 150,
203            wisdom: 150,
204            charisma: 150,
205        }
206    }
207}
208
209impl PrimaryAttributes {
210    /// Map internal 1–1000 to player-facing 1–100.
211    pub fn display(value: u16) -> u16 {
212        (value / 10).clamp(1, 100)
213    }
214
215    /// Client-facing derived stat preview (mirrors sim tuning).
216    pub fn derived_preview(&self) -> DerivedPreview {
217        let str_d = Self::display(self.strength) as f32;
218        let dex_d = Self::display(self.dexterity) as f32;
219        let int_d = Self::display(self.intelligence) as f32;
220        let wis_d = Self::display(self.wisdom) as f32;
221        DerivedPreview {
222            attack_power: str_d * 1.2 + dex_d * 0.3,
223            spell_power: int_d * 1.1 + wis_d * 0.4,
224            evasion: dex_d * 0.8 + wis_d * 0.2,
225            carry_mass_max: str_d * 2.5,
226            sight_range_m: 12.0 + wis_d * 0.15 + dex_d * 0.05,
227            fov_deg: 120.0 + wis_d * 0.2,
228            hearing_range_m: 6.0
229                + wis_d * 0.08
230                + (PrimaryAttributes::display(self.stamina) as f32) * 0.04,
231        }
232    }
233}
234
235/// Derived combat/survival preview for character sheet UI.
236#[derive(Debug, Clone, Copy, PartialEq)]
237pub struct DerivedPreview {
238    pub attack_power: f32,
239    pub spell_power: f32,
240    pub evasion: f32,
241    pub carry_mass_max: f32,
242    pub sight_range_m: f32,
243    pub fov_deg: f32,
244    pub hearing_range_m: f32,
245}
246
247/// Trained skill — internal 0–1000; UI uses tiers / display level.
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
249pub struct SkillProgress {
250    pub level: u16,
251    #[serde(default)]
252    pub last_trained_tick: u64,
253}
254
255impl Default for SkillProgress {
256    fn default() -> Self {
257        Self {
258            level: 0,
259            last_trained_tick: 0,
260        }
261    }
262}
263
264impl SkillProgress {
265    /// Mastery tier 0–10 (`plans/11` §10). Tier 0 = untrained; tier 1 begins at internal 100.
266    pub fn display_tier(&self) -> u16 {
267        (self.level / 100).min(10)
268    }
269}
270
271/// Fractional XP pools — source of truth for progression curve (`plans/27`).
272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
273#[serde(default)]
274pub struct ProgressionXp {
275    pub strength: f64,
276    pub dexterity: f64,
277    pub intelligence: f64,
278    pub stamina: f64,
279    pub vitality: f64,
280    pub wisdom: f64,
281    pub charisma: f64,
282    pub logging: f64,
283    pub mining: f64,
284    pub evocation: f64,
285    pub restoration: f64,
286    pub swords: f64,
287    pub archery: f64,
288    pub crafting: f64,
289    pub alchemy: f64,
290    pub cartography: f64,
291    /// Per-ability mastery XP (`ability_id` → fractional XP). Empty = all abilities tier 0.
292    #[serde(default)]
293    pub ability: std::collections::BTreeMap<String, f64>,
294}
295
296impl ProgressionXp {
297    /// Bootstrap pools for a new character at display baseline 15.
298    pub fn bootstrap_new(baseline_display: u16, xp_base: f64, xp_growth: f64) -> Self {
299        let bootstrap = |display: f64| {
300            if display <= 1.0 {
301                0.0
302            } else {
303                xp_base * xp_growth.powf(display - 1.0)
304            }
305        };
306        let b = baseline_display as f64;
307        let primary = bootstrap(b);
308        Self {
309            strength: primary,
310            dexterity: primary,
311            intelligence: primary,
312            stamina: primary,
313            vitality: primary,
314            wisdom: primary,
315            charisma: primary,
316            ..Self::default()
317        }
318    }
319
320    pub fn is_empty(&self) -> bool {
321        self.strength == 0.0
322            && self.dexterity == 0.0
323            && self.intelligence == 0.0
324            && self.stamina == 0.0
325            && self.vitality == 0.0
326            && self.wisdom == 0.0
327            && self.charisma == 0.0
328            && self.logging == 0.0
329            && self.mining == 0.0
330            && self.evocation == 0.0
331            && self.restoration == 0.0
332            && self.swords == 0.0
333            && self.archery == 0.0
334            && self.crafting == 0.0
335            && self.alchemy == 0.0
336            && self.cartography == 0.0
337            && self.ability.is_empty()
338    }
339}
340
341/// Per-ability mastery row for combat HUD / character sheet.
342#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
343pub struct AbilityMasteryHud {
344    pub ability_id: String,
345    /// Mastery tier 0–10 (`SkillProgress::display_tier`).
346    pub tier: u16,
347    /// Internal level 0–1000.
348    pub level: u16,
349    /// Fractional XP in the ability pool.
350    pub xp: f64,
351    /// XP required to reach the next display step on the progression curve.
352    pub xp_to_next: f64,
353}
354
355/// Core trained skills shipped at launch (`plans/11` §10).
356#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
357#[serde(default)]
358pub struct PlayerSkills {
359    pub logging: SkillProgress,
360    pub mining: SkillProgress,
361    pub evocation: SkillProgress,
362    #[serde(default)]
363    pub restoration: SkillProgress,
364    pub swords: SkillProgress,
365    #[serde(default)]
366    pub archery: SkillProgress,
367    pub crafting: SkillProgress,
368    #[serde(default)]
369    pub alchemy: SkillProgress,
370    pub cartography: SkillProgress,
371}
372
373impl Default for PlayerSkills {
374    fn default() -> Self {
375        Self {
376            logging: SkillProgress::default(),
377            mining: SkillProgress::default(),
378            evocation: SkillProgress::default(),
379            restoration: SkillProgress::default(),
380            swords: SkillProgress::default(),
381            archery: SkillProgress::default(),
382            crafting: SkillProgress::default(),
383            alchemy: SkillProgress::default(),
384            cartography: SkillProgress::default(),
385        }
386    }
387}
388
389/// Player health / resources (players only; omitted on other entities).
390#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
391pub struct PlayerVitals {
392    pub health: f32,
393    pub health_max: f32,
394    pub mana: f32,
395    pub mana_max: f32,
396    pub stamina: f32,
397    pub stamina_max: f32,
398    #[serde(default = "default_survival_pool_max")]
399    pub hunger: f32,
400    #[serde(default = "default_survival_pool_max")]
401    pub hunger_max: f32,
402    #[serde(default = "default_survival_pool_max")]
403    pub thirst: f32,
404    #[serde(default = "default_survival_pool_max")]
405    pub thirst_max: f32,
406    #[serde(default)]
407    pub coins: u32,
408    #[serde(default)]
409    pub deaths: u32,
410    #[serde(default)]
411    pub life_state: LifeState,
412    /// Hysteresis lock: entered at empty stamina, cleared at `vitals.exhaustion.recover_ratio`.
413    #[serde(default)]
414    pub winded: bool,
415}
416
417fn default_survival_pool_max() -> f32 {
418    100.0
419}
420
421impl Default for PlayerVitals {
422    fn default() -> Self {
423        Self::from_attributes(PrimaryAttributes::default())
424    }
425}
426
427impl PlayerVitals {
428    /// Build pool maximums from primary attributes (current pools filled to max).
429    ///
430    /// Uses **display** stats (internal ÷ 10) so pools scale intuitively with
431    /// progression, gear, and future enchant modifiers on attributes.
432    ///
433    /// Default attrs (all 150 → display 15): ~80 HP, ~51 stamina, ~61 mana.
434    pub fn from_attributes(attrs: PrimaryAttributes) -> Self {
435        let vit_d = PrimaryAttributes::display(attrs.vitality) as f32;
436        let int_d = PrimaryAttributes::display(attrs.intelligence) as f32;
437        let wis_d = PrimaryAttributes::display(attrs.wisdom) as f32;
438        let sta_d = PrimaryAttributes::display(attrs.stamina) as f32;
439
440        let health_max = 50.0 + vit_d * 2.0;
441        let stamina_max = 30.0 + sta_d * 1.4;
442        let mana_max = 25.0 + int_d * 1.1 + wis_d * 1.3;
443        let hunger_max = 100.0;
444        let thirst_max = 100.0;
445        Self {
446            health: health_max,
447            health_max,
448            mana: mana_max,
449            mana_max,
450            stamina: stamina_max,
451            stamina_max,
452            hunger: hunger_max,
453            hunger_max,
454            thirst: thirst_max,
455            thirst_max,
456            coins: 0,
457            deaths: 0,
458            life_state: LifeState::Alive,
459            winded: false,
460        }
461    }
462
463    /// Legacy pool maxima (pre-2026-07 pool rebalance) for migration scaling.
464    pub fn legacy_maxima(attrs: PrimaryAttributes) -> (f32, f32, f32) {
465        (
466            attrs.vitality as f32 / 5.0,
467            attrs.stamina as f32 / 5.0,
468            (attrs.intelligence as f32 + attrs.wisdom as f32) / 20.0,
469        )
470    }
471}
472
473/// Durable pool snapshot for Postgres (max values recomputed from attributes on load).
474#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
475#[serde(default)]
476pub struct StoredVitalsState {
477    pub health: f32,
478    pub mana: f32,
479    pub stamina: f32,
480    pub hunger: f32,
481    pub thirst: f32,
482    pub coins: u32,
483    pub deaths: u32,
484    pub life_state: LifeState,
485    #[serde(default)]
486    pub winded: bool,
487}
488
489impl StoredVitalsState {
490    pub fn from_live(v: &PlayerVitals) -> Self {
491        Self {
492            health: v.health,
493            mana: v.mana,
494            stamina: v.stamina,
495            hunger: v.hunger,
496            thirst: v.thirst,
497            coins: v.coins,
498            deaths: v.deaths,
499            life_state: v.life_state,
500            winded: v.winded,
501        }
502    }
503
504    /// Empty `{}` JSON from a new character row — not a real gameplay snapshot.
505    pub fn is_pristine(&self) -> bool {
506        self.health == 0.0
507            && self.mana == 0.0
508            && self.stamina == 0.0
509            && self.hunger == 0.0
510            && self.thirst == 0.0
511            && self.coins == 0
512            && self.deaths == 0
513            && self.life_state == LifeState::Alive
514    }
515
516    pub fn apply_to(self, attrs: PrimaryAttributes) -> PlayerVitals {
517        if self.is_pristine() {
518            return PlayerVitals::from_attributes(attrs);
519        }
520        let fresh = PlayerVitals::from_attributes(attrs);
521        let (legacy_hp, legacy_sta, legacy_mana) = PlayerVitals::legacy_maxima(attrs);
522
523        let scale = |current: f32, legacy_max: f32, new_max: f32| {
524            if legacy_max > 0.0 && new_max > legacy_max * 1.05 && current >= legacy_max * 0.95 {
525                let ratio = (current / legacy_max).clamp(0.0, 1.0);
526                (new_max * ratio).min(new_max)
527            } else {
528                current.min(new_max)
529            }
530        };
531
532        let mut v = fresh;
533        v.health = scale(self.health, legacy_hp, fresh.health_max);
534        v.mana = scale(self.mana, legacy_mana, fresh.mana_max);
535        v.stamina = scale(self.stamina, legacy_sta, fresh.stamina_max);
536        v.hunger = self.hunger.min(v.hunger_max);
537        v.thirst = self.thirst.min(v.thirst_max);
538        v.coins = self.coins;
539        v.deaths = self.deaths;
540        v.life_state = self.life_state;
541        v.winded = self.winded;
542        v
543    }
544}
545
546impl Default for StoredVitalsState {
547    fn default() -> Self {
548        Self::from_live(&PlayerVitals::default())
549    }
550}
551
552/// Fallback display text for snake_case ids when no authored label exists.
553///
554/// Example: `heal_touch` → `Heal Touch`. Prefer catalog/`label` fields when present.
555pub fn humanize_snake_id(id: &str) -> String {
556    id.split('_')
557        .filter(|part| !part.is_empty())
558        .map(|part| {
559            let mut chars = part.chars();
560            match chars.next() {
561                None => String::new(),
562                Some(first) => first.to_uppercase().chain(chars).collect(),
563            }
564        })
565        .collect::<Vec<_>>()
566        .join(" ")
567}
568
569/// Character-owned combat ability (skill book / temporary grant).
570#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
571pub struct KnownAbility {
572    pub ability_id: String,
573    /// When false, `expires_at_tick` must be set.
574    #[serde(default = "default_known_permanent")]
575    pub permanent: bool,
576    /// Sim tick when a temporary grant ends (`None` = permanent).
577    #[serde(default)]
578    pub expires_at_tick: Option<u64>,
579}
580
581fn default_known_permanent() -> bool {
582    true
583}
584
585impl KnownAbility {
586    pub fn permanent(ability_id: impl Into<String>) -> Self {
587        Self {
588            ability_id: ability_id.into(),
589            permanent: true,
590            expires_at_tick: None,
591        }
592    }
593
594    pub fn is_active(&self, tick: u64) -> bool {
595        if self.permanent {
596            return true;
597        }
598        match self.expires_at_tick {
599            Some(exp) => tick < exp,
600            None => false,
601        }
602    }
603}
604
605/// Saved ability rotation preset (`plans/26` §C3).
606#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
607pub struct RotationPreset {
608    pub id: String,
609    pub label: String,
610    #[serde(default)]
611    pub abilities: Vec<String>,
612}
613
614impl RotationPreset {
615    pub fn melee_default(ability_id: impl Into<String>) -> Self {
616        let id = ability_id.into();
617        Self {
618            id: "melee".into(),
619            // Built-in weapon auto preset — ability tracks equipped mainhand.
620            label: "Weapon".into(),
621            abilities: vec![id],
622        }
623    }
624
625    pub fn is_weapon_preset(&self) -> bool {
626        self.id == "melee"
627    }
628}
629
630/// Durable combat state (`plans/12` §4.1) — target by wildlife instance id, not entity id.
631#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
632pub struct StoredTargetSlot {
633    pub instance_id: Option<String>,
634    #[serde(default)]
635    pub preset_id: Option<String>,
636    #[serde(default)]
637    pub rotation_index: u32,
638    #[serde(default)]
639    pub auto_enabled: bool,
640}
641
642#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
643#[serde(default)]
644pub struct StoredCombatProfile {
645    /// Wildlife `instance_id` (e.g. `meadow-rabbits-0`), stable across worker restarts.
646    pub combat_target_instance_id: Option<String>,
647    pub in_combat: bool,
648    pub last_combat_tick: u64,
649    pub last_attack_tick: u64,
650    pub cooldowns_until_tick: BTreeMap<String, u64>,
651    #[serde(default = "default_auto_attack")]
652    pub auto_attack_enabled: bool,
653    /// Equipped mainhand weapon template id (`plans/26` §C2b).
654    #[serde(default)]
655    pub mainhand_template_id: Option<String>,
656    /// Specific mainhand instance (bindings / unique gear).
657    #[serde(default)]
658    pub mainhand_instance_id: Option<Uuid>,
659    /// Equipped offhand (shield / dual-wield). Cleared when mainhand is two-handed.
660    #[serde(default)]
661    pub offhand_template_id: Option<String>,
662    /// Specific offhand instance.
663    #[serde(default)]
664    pub offhand_instance_id: Option<Uuid>,
665    /// Equipped body-slot items (backpack, belt, armor, jewelry), nested contents included
666    /// (e.g. pouches clipped onto a worn belt). At most one entry per `BodySlot`.
667    #[serde(default)]
668    pub worn: Vec<(BodySlot, ItemStack)>,
669    /// Player-authored rotation library (`plans/26` §C3).
670    #[serde(default)]
671    pub rotation_presets: Vec<RotationPreset>,
672    /// Per-target slot state (T1/T2).
673    #[serde(default)]
674    pub target_slots: Vec<StoredTargetSlot>,
675    /// Discovered craft recipe ids (`plans/09` §C2 hybrid discovery).
676    #[serde(default)]
677    pub known_blueprint_ids: Vec<String>,
678    /// Keys stowed on the keychain (not counted toward carry mass).
679    #[serde(default)]
680    pub keychain: Vec<ItemStack>,
681    /// Paired whisper stones ("phones") — zero carry mass; persists with combat profile.
682    #[serde(default)]
683    pub whisper_pouch: Vec<ItemStack>,
684    /// Learned combat abilities (skill books / temporary grants). Empty on load → default unarmed.
685    #[serde(default)]
686    pub known_abilities: Vec<KnownAbility>,
687    /// Hotbar slots 1–9 (`None` / missing = unbound). Empty on load → slot 1 = unarmed.
688    #[serde(default)]
689    pub hotbar: Vec<Option<String>>,
690    /// Bump when combat ability progression model changes. `0` = pre–skill-book open kit.
691    #[serde(default)]
692    pub abilities_schema_version: u32,
693    /// Secure bank ledger balance in copper (`plans/08` §8). Persists with combat profile.
694    #[serde(default)]
695    pub bank_balance_copper: u64,
696}
697
698fn default_auto_attack() -> bool {
699    true
700}
701
702impl Default for StoredCombatProfile {
703    fn default() -> Self {
704        Self {
705            combat_target_instance_id: None,
706            in_combat: false,
707            last_combat_tick: 0,
708            last_attack_tick: 0,
709            cooldowns_until_tick: BTreeMap::new(),
710            auto_attack_enabled: true,
711            mainhand_template_id: None,
712            mainhand_instance_id: None,
713            offhand_template_id: None,
714            offhand_instance_id: None,
715            worn: Vec::new(),
716            rotation_presets: Vec::new(),
717            target_slots: Vec::new(),
718            known_blueprint_ids: Vec::new(),
719            keychain: Vec::new(),
720            whisper_pouch: Vec::new(),
721            known_abilities: Vec::new(),
722            hotbar: Vec::new(),
723            abilities_schema_version: 0,
724            bank_balance_copper: 0,
725        }
726    }
727}
728
729/// Short-lived combat overlay on an entity (dodge/block i-frames, attack wind-up).
730#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
731#[serde(rename_all = "snake_case")]
732pub enum CombatCueKind {
733    Dodge,
734    Block,
735    AttackTelegraph,
736}
737
738#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
739pub struct CombatCueView {
740    pub kind: CombatCueKind,
741    /// Sim tick after which clients should hide this cue.
742    pub until_tick: Tick,
743    /// Sim tick when the cue began (for progress rings). `0` = client estimates from kind.
744    #[serde(default)]
745    pub start_tick: Tick,
746    /// Ability id when `kind` is [`CombatCueKind::AttackTelegraph`].
747    #[serde(default)]
748    pub ability_id: Option<String>,
749    /// When set, clients draw a spatial impact preview (red zone) at this origin.
750    #[serde(default)]
751    pub telegraph_kind: Option<CombatFxKind>,
752    #[serde(default)]
753    pub origin_x: Option<f32>,
754    #[serde(default)]
755    pub origin_y: Option<f32>,
756    #[serde(default)]
757    pub origin_z: Option<f32>,
758    #[serde(default)]
759    pub end_x: Option<f32>,
760    #[serde(default)]
761    pub end_y: Option<f32>,
762    #[serde(default)]
763    pub end_z: Option<f32>,
764    #[serde(default)]
765    pub yaw: Option<f32>,
766    #[serde(default)]
767    pub reach_m: Option<f32>,
768    #[serde(default)]
769    pub arc_deg: Option<f32>,
770    #[serde(default)]
771    pub radius_m: Option<f32>,
772}
773
774impl CombatCueView {
775    /// Timing-only cue (dodge / block / entity wind-up without impact preview).
776    pub fn timing(kind: CombatCueKind, until_tick: Tick, start_tick: Tick) -> Self {
777        Self {
778            kind,
779            until_tick,
780            start_tick,
781            ability_id: None,
782            telegraph_kind: None,
783            origin_x: None,
784            origin_y: None,
785            origin_z: None,
786            end_x: None,
787            end_y: None,
788            end_z: None,
789            yaw: None,
790            reach_m: None,
791            arc_deg: None,
792            radius_m: None,
793        }
794    }
795}
796
797#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
798pub struct EntityState {
799    pub id: EntityId,
800    pub transform: Transform,
801    /// Character / display name (first letter used as map glyph for other players).
802    #[serde(default)]
803    pub label: String,
804    #[serde(default)]
805    pub vitals: Option<PlayerVitals>,
806    /// Primary attributes (local player / inspect).
807    #[serde(default)]
808    pub attributes: Option<PrimaryAttributes>,
809    #[serde(default)]
810    pub skills: Option<PlayerSkills>,
811    /// When inside a building interior (players only).
812    #[serde(default)]
813    pub inside_building: Option<String>,
814    /// Gfx sprite sheet id for non-character entities. Player/NPC avatars use `paperdoll_ref` only.
815    #[serde(default)]
816    pub tile_id: Option<String>,
817    /// Paperdoll skin id (`assets/paperdoll/skins/`). Client prefers this over `tile_id` when baked.
818    #[serde(default)]
819    pub paperdoll_ref: Option<String>,
820    /// World draw size in map cells (from paperdoll skin `draw_scale`; 1.0 = one cell).
821    #[serde(default = "default_draw_scale")]
822    pub draw_scale: f32,
823    /// Canonical gfx presentation key (`walking`, `combat`, `harvesting`, …).
824    #[serde(default)]
825    pub presentation_state: Option<String>,
826    /// Resolved gfx sprite mode for `tile_id` (server-computed).
827    #[serde(default)]
828    pub sprite_mode: Option<String>,
829    /// XP pools for local player progression display (omitted on NPCs / other players).
830    #[serde(default)]
831    pub progression_xp: Option<ProgressionXp>,
832    /// Active dodge/block windows and attack telegraphs for map FX.
833    #[serde(default)]
834    pub combat_cues: Vec<CombatCueView>,
835    /// Active status effects for world indicators (AOI entities / other players).
836    #[serde(default)]
837    pub statuses: Vec<StatusEffectHud>,
838}
839
840/// In-world speech / stone contact channels.
841#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
842#[serde(rename_all = "snake_case")]
843pub enum ChatChannel {
844    /// Untargeted voice — everyone in acoustic range may hear.
845    Nearby,
846    /// Directed speak at a nearby player (eavesdroppers may overhear).
847    Direct,
848    /// Directed private whisper (range 0 for eavesdroppers).
849    Whisper,
850    /// Long-range contact via paired whisper stones ("phones").
851    WhisperStone,
852}
853
854/// How clearly this recipient heard the line (server-authored text already matches).
855#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
856#[serde(rename_all = "snake_case")]
857pub enum ChatClarity {
858    #[default]
859    Clear,
860    Partial,
861    Heavy,
862}
863
864#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
865pub struct ChatMessage {
866    pub channel: ChatChannel,
867    pub from_entity: EntityId,
868    pub from_name: String,
869    /// Text as this recipient should see it (already garbled when unclear).
870    pub text: String,
871    pub tick: Tick,
872    /// Directed / stone peer (omitted for untargeted Nearby).
873    #[serde(default)]
874    pub to_entity: Option<EntityId>,
875    #[serde(default)]
876    pub clarity: ChatClarity,
877}
878
879/// Client → server gameplay input (reliable, sequenced per entity).
880#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
881pub enum Intent {
882    Move {
883        entity_id: EntityId,
884        forward: f32,
885        strafe: f32,
886        /// Climb (+) or descend (−) on z axis (m/s intent).
887        #[serde(default)]
888        vertical: f32,
889        /// Sprint multiplier when stamina allows.
890        #[serde(default)]
891        sprint: bool,
892        /// Quiet slow movement (overrides sprint when both set).
893        #[serde(default)]
894        sneak: bool,
895        seq: Seq,
896    },
897    Stop {
898        entity_id: EntityId,
899        seq: Seq,
900    },
901    Harvest {
902        entity_id: EntityId,
903        node_id: String,
904        seq: Seq,
905    },
906    Use {
907        entity_id: EntityId,
908        template_id: String,
909        seq: Seq,
910    },
911    /// Apply a grant consumable onto a specific item instance (unique gear status bindings).
912    UseGrant {
913        entity_id: EntityId,
914        grant_instance_id: Uuid,
915        target_instance_id: Uuid,
916        seq: Seq,
917    },
918    Say {
919        entity_id: EntityId,
920        channel: ChatChannel,
921        text: String,
922        /// Required for Direct / Whisper / WhisperStone (peer entity).
923        #[serde(default)]
924        to_entity: Option<EntityId>,
925        seq: Seq,
926    },
927    /// Start a blueprint craft (timed, like harvest).
928    Craft {
929        entity_id: EntityId,
930        blueprint_id: String,
931        /// Batches to run back-to-back; `None` crafts as many as materials/stamina allow.
932        #[serde(default)]
933        count: Option<u32>,
934        seq: Seq,
935    },
936    /// Door, NPC, enter/exit building.
937    Interact {
938        entity_id: EntityId,
939        target_id: String,
940        seq: Seq,
941    },
942    /// Buy from an NPC shop offer (`ShopOpened` catalog).
943    ShopBuy {
944        entity_id: EntityId,
945        npc_id: String,
946        offer_id: String,
947        #[serde(default = "default_one")]
948        quantity: u32,
949        seq: Seq,
950    },
951    /// Sell inventory to an NPC (`ShopOpened` buy list).
952    ShopSell {
953        entity_id: EntityId,
954        npc_id: String,
955        template_id: String,
956        #[serde(default = "default_one")]
957        quantity: u32,
958        seq: Seq,
959    },
960    /// Close an open NPC shop UI (releases the NPC movement pin).
961    ShopClose {
962        entity_id: EntityId,
963        npc_id: String,
964        seq: Seq,
965    },
966    /// Dev / test: apply damage to self (co-op testing).
967    TestDamage {
968        entity_id: EntityId,
969        amount: f32,
970        seq: Seq,
971    },
972    /// Slot-1 combat target (`plans/12` alias).
973    SetTarget {
974        entity_id: EntityId,
975        target_id: EntityId,
976        seq: Seq,
977    },
978    /// Target slot assignment (`plans/12` §5.3). Slot 1 aliases `SetTarget`.
979    SetTargetSlot {
980        entity_id: EntityId,
981        slot_index: u8,
982        target_id: EntityId,
983        seq: Seq,
984    },
985    ClearTarget {
986        entity_id: EntityId,
987        seq: Seq,
988    },
989    ClearTargetSlot {
990        entity_id: EntityId,
991        slot_index: u8,
992        seq: Seq,
993    },
994    /// Toggle per-slot auto-attack (`plans/12` §4.2).
995    SetAutoAttack {
996        entity_id: EntityId,
997        slot_index: u8,
998        enabled: bool,
999        seq: Seq,
1000    },
1001    /// Melee attack — uses `target_id` or the player's current target.
1002    Attack {
1003        entity_id: EntityId,
1004        #[serde(default)]
1005        target_id: Option<EntityId>,
1006        #[serde(default)]
1007        weapon_slot: Option<u32>,
1008        seq: Seq,
1009    },
1010    /// Pick up a ground loot pile (nearest in range when `drop_id` omitted).
1011    Pickup {
1012        entity_id: EntityId,
1013        #[serde(default)]
1014        drop_id: Option<String>,
1015        seq: Seq,
1016    },
1017    /// Cast a spell or use a non-weapon ability template (`plans/24` §4.3b).
1018    /// Optional [`target_point`] aims AoE / ground spells at a map location (Shift+click).
1019    Cast {
1020        entity_id: EntityId,
1021        ability_id: String,
1022        target_id: EntityId,
1023        #[serde(default)]
1024        target_point: Option<AimPoint>,
1025        seq: Seq,
1026    },
1027    /// Bind an ability to a target slot action bar (`plans/12` §4.2).
1028    BindActionSlot {
1029        entity_id: EntityId,
1030        slot_index: u8,
1031        ability_id: String,
1032        #[serde(default = "default_auto_attack")]
1033        auto_enabled: bool,
1034        seq: Seq,
1035    },
1036    /// Fire a bound consumable or ability from a slot (`plans/24` §4.3c).
1037    UseActionSlot {
1038        entity_id: EntityId,
1039        slot_index: u8,
1040        seq: Seq,
1041    },
1042    /// Dodge — brief i-frames, stamina cost; re-press during window refreshes (`plans/24` §4.3c).
1043    /// When `forward`/`strafe` are non-zero (WASD held), burst that way if walkable;
1044    /// otherwise the sim picks a smart auto destination (telegraph exit / rear).
1045    Dodge {
1046        entity_id: EntityId,
1047        /// Movement forward axis when dodging with WASD (−1..1). `0` = auto aim.
1048        #[serde(default)]
1049        forward: f32,
1050        /// Movement strafe axis when dodging with WASD (−1..1). `0` = auto aim.
1051        #[serde(default)]
1052        strafe: f32,
1053        seq: Seq,
1054    },
1055    /// Lunge — burst forward in movement/facing direction, higher stamina cost.
1056    Lunge {
1057        entity_id: EntityId,
1058        /// Last movement forward axis when idle (−1..1).
1059        #[serde(default)]
1060        forward: f32,
1061        /// Last movement strafe axis when idle (−1..1).
1062        #[serde(default)]
1063        strafe: f32,
1064        seq: Seq,
1065    },
1066    /// Directional jump — leap one cell uphill over a medium cliff (`plans/04` §4.5b).
1067    DirectionalJump {
1068        entity_id: EntityId,
1069        /// Jump direction forward axis (−1..1).
1070        #[serde(default)]
1071        forward: f32,
1072        /// Jump direction strafe axis (−1..1).
1073        #[serde(default)]
1074        strafe: f32,
1075        seq: Seq,
1076    },
1077    /// Block — frontal mitigation while held (`plans/24` §4.3c).
1078    Block {
1079        entity_id: EntityId,
1080        #[serde(default = "default_block_enabled")]
1081        enabled: bool,
1082        seq: Seq,
1083    },
1084    /// Equip or clear mainhand weapon (`plans/26` §C2b). `None` unequips.
1085    /// Two-handed weapons (`hand_slots: 2`) clear offhand on equip.
1086    /// Prefer `instance_id` so unique bindings travel with that weapon instance.
1087    EquipMainhand {
1088        entity_id: EntityId,
1089        #[serde(default)]
1090        template_id: Option<String>,
1091        #[serde(default)]
1092        instance_id: Option<Uuid>,
1093        seq: Seq,
1094    },
1095    /// Equip or clear offhand (shield / dual-wield). Rejected while mainhand is two-handed.
1096    EquipOffhand {
1097        entity_id: EntityId,
1098        #[serde(default)]
1099        template_id: Option<String>,
1100        #[serde(default)]
1101        instance_id: Option<Uuid>,
1102        seq: Seq,
1103    },
1104    /// Equip a wearable (container, belt, armor, jewelry) to a body slot, or clear the
1105    /// slot when `instance_id` is None.
1106    EquipWorn {
1107        entity_id: EntityId,
1108        slot: BodySlot,
1109        #[serde(default)]
1110        instance_id: Option<Uuid>,
1111        seq: Seq,
1112    },
1113    /// Move an item instance between root / worn / placed container inventories.
1114    MoveItem {
1115        entity_id: EntityId,
1116        item_instance_id: Uuid,
1117        from: InventoryLocation,
1118        to: InventoryLocation,
1119        /// When moving into a container location, nest under this parent instance (None = container root).
1120        #[serde(default)]
1121        to_parent_instance_id: Option<Uuid>,
1122        /// Units to move; `None` moves the whole stack. Server clamps to volume/carry limits.
1123        #[serde(default)]
1124        quantity: Option<u32>,
1125        seq: Seq,
1126    },
1127    /// Place a placeable container from inventory onto the ground at the player's feet.
1128    PlaceContainer {
1129        entity_id: EntityId,
1130        item_instance_id: Uuid,
1131        seq: Seq,
1132    },
1133    /// Pick up a placed container (with contents) into inventory.
1134    PickupContainer {
1135        entity_id: EntityId,
1136        container_id: String,
1137        seq: Seq,
1138    },
1139    /// Relocate a placed container on the map without picking it up (keeps id / lodging).
1140    MovePlacedContainer {
1141        entity_id: EntityId,
1142        container_id: String,
1143        x: f32,
1144        y: f32,
1145        seq: Seq,
1146    },
1147    /// Lock or unlock a worn or placed container (requires matching key when locking/unlocking).
1148    SetContainerLocked {
1149        entity_id: EntityId,
1150        /// Worn slot or placed container id encoded as location.
1151        location: InventoryLocation,
1152        locked: bool,
1153        seq: Seq,
1154    },
1155    /// Drop a non-container item on the ground at the player's feet.
1156    DropItem {
1157        entity_id: EntityId,
1158        item_instance_id: Uuid,
1159        from: InventoryLocation,
1160        seq: Seq,
1161    },
1162    /// Permanently destroy an item stack (not recoverable; no ground drop).
1163    DestroyItem {
1164        entity_id: EntityId,
1165        item_instance_id: Uuid,
1166        from: InventoryLocation,
1167        /// Units to destroy; `None` destroys the whole stack.
1168        #[serde(default)]
1169        quantity: Option<u32>,
1170        seq: Seq,
1171    },
1172    /// Set a custom display name on a container instance (chest, pouch, backpack).
1173    RenameContainer {
1174        entity_id: EntityId,
1175        item_instance_id: Uuid,
1176        location: InventoryLocation,
1177        name: String,
1178        seq: Seq,
1179    },
1180    /// Create or update a rotation preset in the player's library.
1181    UpsertRotationPreset {
1182        entity_id: EntityId,
1183        preset: RotationPreset,
1184        seq: Seq,
1185    },
1186    /// Remove a rotation preset from the library.
1187    DeleteRotationPreset {
1188        entity_id: EntityId,
1189        preset_id: String,
1190        seq: Seq,
1191    },
1192    /// Assign a library preset to target slot T1/T2.
1193    AssignSlotPreset {
1194        entity_id: EntityId,
1195        slot_index: u8,
1196        preset_id: String,
1197        seq: Seq,
1198    },
1199    /// Bind or clear a hotbar slot (`1`–`9`) to a learned / weapon ability
1200    /// or an inventory consumable (`item:<template_id>` — see [`hotbar_consumable_binding`]).
1201    SetHotbarSlot {
1202        entity_id: EntityId,
1203        /// 1–9
1204        slot: u8,
1205        /// `None` clears the slot. Ability id, or `item:<template_id>` for consumables.
1206        #[serde(default)]
1207        ability_id: Option<String>,
1208        seq: Seq,
1209    },
1210    /// Fire the next ready ability in a slot's rotation (manual step).
1211    AdvanceRotation {
1212        entity_id: EntityId,
1213        slot_index: u8,
1214        seq: Seq,
1215    },
1216    /// Open a turn-based conversation with an NPC.
1217    NpcTalkOpen {
1218        entity_id: EntityId,
1219        npc_id: String,
1220        /// When set, deliver that quest's offer or `talk_npc` line. Casual Talk omits this.
1221        #[serde(default)]
1222        quest_id: Option<String>,
1223        seq: Seq,
1224    },
1225    /// Send a player message in an open NPC conversation.
1226    NpcTalkSay {
1227        entity_id: EntityId,
1228        npc_id: String,
1229        message: String,
1230        seq: Seq,
1231    },
1232    /// Close an NPC conversation.
1233    NpcTalkClose {
1234        entity_id: EntityId,
1235        npc_id: String,
1236        seq: Seq,
1237    },
1238    /// Accept a discovered quest (adds to active list).
1239    AcceptQuest {
1240        entity_id: EntityId,
1241        quest_id: String,
1242        seq: Seq,
1243    },
1244    /// Withdraw from an active quest (resets progress; can re-accept).
1245    WithdrawQuest {
1246        entity_id: EntityId,
1247        quest_id: String,
1248        seq: Seq,
1249    },
1250    /// Highlight an active quest in the HUD.
1251    TrackQuest {
1252        entity_id: EntityId,
1253        quest_id: String,
1254        seq: Seq,
1255    },
1256    /// Turn in items for an active give_item objective while near an NPC.
1257    QuestGiveItem {
1258        entity_id: EntityId,
1259        npc_id: String,
1260        template_id: String,
1261        #[serde(default = "default_one")]
1262        quantity: u32,
1263        seq: Seq,
1264    },
1265    /// Hire an NPC worker (fee + recurring wage).
1266    HireWorker {
1267        entity_id: EntityId,
1268        def_id: String,
1269        wage_copper_per_interval: u32,
1270        #[serde(default)]
1271        lodging_container_id: Option<String>,
1272        #[serde(default)]
1273        job_yaml: Option<String>,
1274        seq: Seq,
1275    },
1276    /// Release a hired worker instance.
1277    DismissWorker {
1278        entity_id: EntityId,
1279        worker_instance_id: String,
1280        seq: Seq,
1281    },
1282    /// Replace or assign a worker job YAML loop.
1283    SetWorkerJob {
1284        entity_id: EntityId,
1285        worker_instance_id: String,
1286        job_yaml: String,
1287        seq: Seq,
1288    },
1289    /// Point a worker at a camp bed / lodging container.
1290    AssignWorkerLodging {
1291        entity_id: EntityId,
1292        worker_instance_id: String,
1293        lodging_container_id: String,
1294        seq: Seq,
1295    },
1296    /// Switch companion vs job-loop automation mode.
1297    SetWorkerMode {
1298        entity_id: EntityId,
1299        worker_instance_id: String,
1300        mode: String,
1301        seq: Seq,
1302    },
1303    /// Equip one item from the employer's inventory on a hired worker.
1304    ///
1305    /// `slot` accepts `mainhand`, `offhand`, or a `BodySlot` name such as
1306    /// `chest` / `head`. The item remains owned by the worker while equipped
1307    /// and is included in worker snapshots.
1308    EquipWorkerItem {
1309        entity_id: EntityId,
1310        worker_instance_id: String,
1311        item_instance_id: uuid::Uuid,
1312        slot: String,
1313        seq: Seq,
1314    },
1315    /// Hand an item from the player's inventory to a hired worker (e.g. a tool the
1316    /// worker must carry but not consume, like a handsaw for `oak_to_lumber`).
1317    GiveWorkerItem {
1318        entity_id: EntityId,
1319        worker_instance_id: String,
1320        item_instance_id: uuid::Uuid,
1321        #[serde(default)]
1322        quantity: Option<u32>,
1323        seq: Seq,
1324    },
1325    /// Take an item from a hired worker's inventory back into the employer's root.
1326    TakeWorkerItem {
1327        entity_id: EntityId,
1328        worker_instance_id: String,
1329        item_instance_id: uuid::Uuid,
1330        #[serde(default)]
1331        quantity: Option<u32>,
1332        seq: Seq,
1333    },
1334    /// Set a custom display name for a hired worker (shown in menus / route editor).
1335    RenameHiredWorker {
1336        entity_id: EntityId,
1337        worker_instance_id: String,
1338        name: String,
1339        seq: Seq,
1340    },
1341    /// Rename an owned property plot label (deed title and Location HUD follow).
1342    RenamePropertyPlot {
1343        entity_id: EntityId,
1344        plot_id: Uuid,
1345        label: String,
1346        seq: Seq,
1347    },
1348    /// Teach a known blueprint to a hired worker (costs `worker_train_copper`).
1349    TeachWorkerBlueprint {
1350        entity_id: EntityId,
1351        worker_instance_id: String,
1352        blueprint_id: String,
1353        seq: Seq,
1354    },
1355    /// Employer opened/closed the worker manage UI (`f` next to them) — pause job steps.
1356    AttendHiredWorker {
1357        entity_id: EntityId,
1358        worker_instance_id: String,
1359        attending: bool,
1360        seq: Seq,
1361    },
1362    /// Buy a fractional plot inside a crown property zone (plan 40). Mints a deed.
1363    BuyPlot {
1364        entity_id: EntityId,
1365        zone_id: String,
1366        x0: f32,
1367        y0: f32,
1368        x1: f32,
1369        y1: f32,
1370        seq: Seq,
1371    },
1372    /// Claim the largest free AABB in a property zone (plan 40).
1373    BuyPlotAllFree {
1374        entity_id: EntityId,
1375        zone_id: String,
1376        seq: Seq,
1377    },
1378    /// Sell a plot back to the crown (requires holding the deed).
1379    SellPlotToCrown {
1380        entity_id: EntityId,
1381        plot_id: Uuid,
1382        seq: Seq,
1383    },
1384    /// Cultivate one cell under/near the player into tilled soil (plan 40 P1).
1385    Cultivate {
1386        entity_id: EntityId,
1387        /// World cell to till (floor coords). Must be on an owned plot.
1388        x: f32,
1389        y: f32,
1390        seq: Seq,
1391    },
1392    /// Plant seeds onto free tilled cells on owned plots near the player (plan 40 P2).
1393    PlantSeeds {
1394        entity_id: EntityId,
1395        seed_template_id: String,
1396        quantity: u32,
1397        seq: Seq,
1398    },
1399    /// Toggle public farm access + public tax discount on an owned plot.
1400    SetPlotFarmPublic {
1401        entity_id: EntityId,
1402        plot_id: Uuid,
1403        public: bool,
1404        #[serde(default)]
1405        public_tax_discount_bps: u32,
1406        seq: Seq,
1407    },
1408    /// Add or update a named tenant farm grant (negotiated tax discount).
1409    PlotFarmAllowUpsert {
1410        entity_id: EntityId,
1411        plot_id: Uuid,
1412        /// Preferred when known.
1413        #[serde(default)]
1414        character_id: Option<Uuid>,
1415        /// Fallback: match online player display name (case-insensitive).
1416        #[serde(default)]
1417        character_name: String,
1418        #[serde(default)]
1419        tax_discount_bps: u32,
1420        seq: Seq,
1421    },
1422    /// Remove a named tenant from a plot's farm allow-list.
1423    PlotFarmAllowRemove {
1424        entity_id: EntityId,
1425        plot_id: Uuid,
1426        character_id: Uuid,
1427        seq: Seq,
1428    },
1429    /// Start timed craft to build a shell on the plot's solid tilled rectangle (plan 20 §18).
1430    /// Materials are taken from town storage (plot in boundary) or a nearby chest (outside).
1431    StartPlotBuild {
1432        entity_id: EntityId,
1433        plot_id: Uuid,
1434        wall_material_id: String,
1435        roof_material_id: String,
1436        seq: Seq,
1437    },
1438    CancelPlotBuild {
1439        entity_id: EntityId,
1440        seq: Seq,
1441    },
1442    /// Lock/unlock a player building exterior door (requires matching key).
1443    /// Door must be closed to lock.
1444    SetDoorLocked {
1445        entity_id: EntityId,
1446        door_id: String,
1447        locked: bool,
1448        seq: Seq,
1449    },
1450    /// Walk in through an already-open player-building exterior door.
1451    /// Latch open/close stays on [`Intent::Interact`] (`f`); map buildings still use open→enter.
1452    EnterBuildingDoor {
1453        entity_id: EntityId,
1454        door_id: String,
1455        seq: Seq,
1456    },
1457    /// Leave through an exterior portal from inside a player building.
1458    /// Always allowed even if the door is closed or locked (anti-trap).
1459    ExitBuildingDoor {
1460        entity_id: EntityId,
1461        door_id: String,
1462        seq: Seq,
1463    },
1464    /// Apply interior room layout; copper charged once on confirm.
1465    ConfirmInteriorEdit {
1466        entity_id: EntityId,
1467        building_id: String,
1468        rooms: Vec<InteriorRoomEdit>,
1469        room_doors: Vec<InteriorRoomDoorEdit>,
1470        seq: Seq,
1471    },
1472    CancelInteriorEdit {
1473        entity_id: EntityId,
1474        building_id: String,
1475        seq: Seq,
1476    },
1477    /// Deposit physical coins into the bank ledger at a teller (`plans/08` §8).
1478    BankDeposit {
1479        entity_id: EntityId,
1480        npc_id: String,
1481        /// Copper to deposit; `0` means deposit all on-person copper.
1482        #[serde(default)]
1483        amount_copper: u64,
1484        seq: Seq,
1485    },
1486    /// Withdraw copper from the bank ledger as physical coins at a teller.
1487    BankWithdraw {
1488        entity_id: EntityId,
1489        npc_id: String,
1490        /// Copper to withdraw; `0` means withdraw all bank balance.
1491        #[serde(default)]
1492        amount_copper: u64,
1493        seq: Seq,
1494    },
1495    /// Close the bank teller UI.
1496    BankClose {
1497        entity_id: EntityId,
1498        npc_id: String,
1499        seq: Seq,
1500    },
1501    /// Magical clearinghouse transfer to another character's bank ledger (`plans/08` §8.3).
1502    BankTransfer {
1503        entity_id: EntityId,
1504        npc_id: String,
1505        /// Recipient character id (preferred when known).
1506        #[serde(default)]
1507        to_character_id: Option<Uuid>,
1508        /// Fallback: match an online player's display name (case-insensitive).
1509        #[serde(default)]
1510        to_name: String,
1511        /// Copper to send (fee is extra, taken from sender bank balance).
1512        amount_copper: u64,
1513        seq: Seq,
1514    },
1515    /// Store an on-person item into the town storage vault at a storage manager.
1516    StorageStore {
1517        entity_id: EntityId,
1518        npc_id: String,
1519        item_instance_id: Uuid,
1520        #[serde(default)]
1521        quantity: Option<u32>,
1522        seq: Seq,
1523    },
1524    /// Take an item from the town storage vault onto person.
1525    StorageTake {
1526        entity_id: EntityId,
1527        npc_id: String,
1528        item_instance_id: Uuid,
1529        #[serde(default)]
1530        quantity: Option<u32>,
1531        seq: Seq,
1532    },
1533    /// Ship vault items to another storage building (distance fee + travel time).
1534    StorageShip {
1535        entity_id: EntityId,
1536        npc_id: String,
1537        dest_building_id: String,
1538        item_instance_id: Uuid,
1539        #[serde(default)]
1540        quantity: Option<u32>,
1541        seq: Seq,
1542    },
1543    /// Close the storage manager UI.
1544    StorageClose {
1545        entity_id: EntityId,
1546        npc_id: String,
1547        seq: Seq,
1548    },
1549    /// List goods from person or town storage into a market hall's escrow
1550    /// (`plans/10-economy-and-markets.md` §4.3).
1551    MarketList {
1552        entity_id: EntityId,
1553        npc_id: String,
1554        source: GoodsLocation,
1555        item_instance_id: Uuid,
1556        #[serde(default)]
1557        quantity: Option<u32>,
1558        unit_price_copper: u64,
1559        /// When true, `unit_price_copper` is ignored — dump-queue mode (`plans/53`).
1560        #[serde(default)]
1561        npc_price: bool,
1562        seq: Seq,
1563    },
1564    /// Change the unit price of one of the caller's own listings.
1565    MarketReprice {
1566        entity_id: EntityId,
1567        npc_id: String,
1568        listing_id: Uuid,
1569        unit_price_copper: u64,
1570        seq: Seq,
1571    },
1572    /// Pull one of the caller's own listings back out of escrow.
1573    MarketDelist {
1574        entity_id: EntityId,
1575        npc_id: String,
1576        listing_id: Uuid,
1577        dest: GoodsLocation,
1578        seq: Seq,
1579    },
1580    /// Buy some or all of a listing's remaining quantity (§4.4).
1581    MarketBuy {
1582        entity_id: EntityId,
1583        npc_id: String,
1584        listing_id: Uuid,
1585        #[serde(default = "default_one")]
1586        quantity: u32,
1587        dest: GoodsLocation,
1588        seq: Seq,
1589    },
1590    /// Close the market clerk UI.
1591    MarketClose {
1592        entity_id: EntityId,
1593        npc_id: String,
1594        seq: Seq,
1595    },
1596    /// Request a player-to-player trade with a nearby peer.
1597    TradeRequest {
1598        entity_id: EntityId,
1599        peer_entity_id: EntityId,
1600        seq: Seq,
1601    },
1602    /// Accept or decline a pending trade request.
1603    TradeRespond {
1604        entity_id: EntityId,
1605        peer_entity_id: EntityId,
1606        accept: bool,
1607        seq: Seq,
1608    },
1609    /// Move an inventory item into the trade escrow (presented bucket).
1610    TradePresent {
1611        entity_id: EntityId,
1612        item_instance_id: Uuid,
1613        #[serde(default)]
1614        quantity: Option<u32>,
1615        seq: Seq,
1616    },
1617    /// Return a presented item from escrow to inventory.
1618    TradeUnpresent {
1619        entity_id: EntityId,
1620        item_instance_id: Uuid,
1621        seq: Seq,
1622    },
1623    /// Toggle ready / handshake on the open trade.
1624    TradeSetReady {
1625        entity_id: EntityId,
1626        ready: bool,
1627        seq: Seq,
1628    },
1629    /// Cancel the open trade (or withdraw a pending request).
1630    TradeCancel {
1631        entity_id: EntityId,
1632        seq: Seq,
1633    },
1634    /// Destroy a paired whisper stone from the whisper pouch (cut contact).
1635    DestroyWhisperStone {
1636        entity_id: EntityId,
1637        item_instance_id: Uuid,
1638        seq: Seq,
1639    },
1640    /// Stow a blank or paired whisper stone from inventory into the pouch.
1641    StowWhisperStone {
1642        entity_id: EntityId,
1643        item_instance_id: Uuid,
1644        seq: Seq,
1645    },
1646    /// Send a companion worker on a one-shot run to deposit all carried items
1647    /// into the nearest employer-owned storage in the worker's jurisdiction.
1648    DeliverWorkerToNearestStorage {
1649        entity_id: EntityId,
1650        worker_instance_id: String,
1651        seq: Seq,
1652    },
1653    /// Cancel an active companion delivery and leave the worker where they are.
1654    CancelWorkerDelivery {
1655        entity_id: EntityId,
1656        worker_instance_id: String,
1657        seq: Seq,
1658    },
1659    /// Destroy a crafted inventory item and refund some blueprint materials.
1660    DeconstructItem {
1661        entity_id: EntityId,
1662        item_instance_id: Uuid,
1663        from: InventoryLocation,
1664        /// Units to deconstruct; `None` deconstructs the whole stack.
1665        #[serde(default)]
1666        quantity: Option<u32>,
1667        seq: Seq,
1668    },
1669}
1670
1671fn default_block_enabled() -> bool {
1672    true
1673}
1674
1675/// One active status effect for HUD (buff/debuff icon strip).
1676#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1677pub struct StatusEffectHud {
1678    pub effect_id: String,
1679    pub label: String,
1680    #[serde(default)]
1681    pub polarity: String,
1682    #[serde(default)]
1683    pub icon_tile_id: Option<String>,
1684    /// Designer-authored dot color (`#RRGGBB`); gfx falls back to polarity tint when absent.
1685    #[serde(default)]
1686    pub dot_color: Option<String>,
1687    /// Remaining duration in seconds (`None` when permanent while-equipped).
1688    #[serde(default)]
1689    pub remaining_sec: Option<f32>,
1690    /// Concurrent stacks of this effect (1 when not stacking).
1691    #[serde(default = "default_stack_count")]
1692    pub stack_count: u8,
1693}
1694
1695fn default_stack_count() -> u8 {
1696    1
1697}
1698
1699/// Observer combat HUD (local player only).
1700#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1701pub struct CombatTargetHud {
1702    pub entity_id: EntityId,
1703    #[serde(default)]
1704    pub label: String,
1705    #[serde(default)]
1706    pub level: u32,
1707    pub health: f32,
1708    pub health_max: f32,
1709    #[serde(default)]
1710    pub life_state: LifeState,
1711    #[serde(default)]
1712    pub distance_m: f32,
1713    #[serde(default)]
1714    pub statuses: Vec<StatusEffectHud>,
1715}
1716
1717/// Kind of in-world timed channel (farming, crafting stubs, …).
1718#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1719#[serde(rename_all = "snake_case")]
1720pub enum TimedChannelKind {
1721    #[default]
1722    Cultivate,
1723    Plant,
1724    Harvest,
1725    /// Timed player-building craft on a plot tilled pad.
1726    Build,
1727    /// Inventory craft channel (n-menu recipes).
1728    Craft,
1729}
1730
1731/// Local-player timed action (till, plant, …) — same progress shape as [`CastProgressHud`].
1732#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1733pub struct TimedChannelHud {
1734    #[serde(default)]
1735    pub label: String,
1736    #[serde(default)]
1737    pub channel: TimedChannelKind,
1738    #[serde(default)]
1739    pub cell_x: i32,
1740    #[serde(default)]
1741    pub cell_y: i32,
1742    /// Optional world AABB for multi-cell channels (plot build pad). Zero = unused.
1743    #[serde(default)]
1744    pub x0: f32,
1745    #[serde(default)]
1746    pub y0: f32,
1747    #[serde(default)]
1748    pub x1: f32,
1749    #[serde(default)]
1750    pub y1: f32,
1751    #[serde(default)]
1752    pub ticks_remaining: u64,
1753    #[serde(default)]
1754    pub ticks_total: u64,
1755}
1756
1757impl TimedChannelHud {
1758    /// True when this channel carries a drawable footprint AABB.
1759    pub fn has_footprint(&self) -> bool {
1760        self.x1 > self.x0 && self.y1 > self.y0
1761    }
1762}
1763
1764/// Where plot-build materials are drawn from (plan 20 §18).
1765#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1766#[serde(rename_all = "snake_case")]
1767pub enum PlotBuildMaterialSource {
1768    #[default]
1769    None,
1770    TownStorage,
1771    NearbyContainer,
1772}
1773
1774/// One catalog pack selectable as walls and/or roof.
1775#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1776pub struct BuildingMaterialView {
1777    pub id: String,
1778    pub display_name: String,
1779    #[serde(default)]
1780    pub can_wall: bool,
1781    #[serde(default)]
1782    pub can_roof: bool,
1783    #[serde(default)]
1784    pub wall_set: String,
1785    #[serde(default)]
1786    pub roof_set: String,
1787    #[serde(default = "default_material_tick_mult")]
1788    pub tick_mult: f32,
1789    #[serde(default)]
1790    pub wall_bom: Vec<BuildingBomLineView>,
1791    #[serde(default)]
1792    pub roof_bom: Vec<BuildingBomLineView>,
1793}
1794
1795fn default_material_tick_mult() -> f32 {
1796    1.0
1797}
1798
1799#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1800pub struct BuildingBomLineView {
1801    pub template_id: String,
1802    #[serde(default)]
1803    pub display_name: String,
1804    pub per_m2: f32,
1805}
1806
1807/// Available qty of one template in the eligible build material pool.
1808#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1809pub struct PlotBuildStockView {
1810    pub template_id: String,
1811    #[serde(default)]
1812    pub display_name: String,
1813    pub quantity: u32,
1814}
1815
1816/// Pad + storage pool for the B build menu (owned plot underfoot, no building yet).
1817#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1818pub struct PlotBuildOfferHud {
1819    pub plot_id: Uuid,
1820    #[serde(default)]
1821    pub pad_width_m: f32,
1822    #[serde(default)]
1823    pub pad_depth_m: f32,
1824    #[serde(default)]
1825    pub pad_ok: bool,
1826    #[serde(default)]
1827    pub pad_error: String,
1828    #[serde(default)]
1829    pub source: PlotBuildMaterialSource,
1830    #[serde(default)]
1831    pub source_label: String,
1832    #[serde(default)]
1833    pub available: Vec<PlotBuildStockView>,
1834    #[serde(default)]
1835    pub base_ticks: u32,
1836    #[serde(default)]
1837    pub tick_per_m2: u32,
1838}
1839
1840/// Active spell cast channel progress.
1841#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1842pub struct CastProgressHud {
1843    #[serde(default)]
1844    pub ability_id: String,
1845    #[serde(default)]
1846    pub ability_label: String,
1847    #[serde(default)]
1848    pub ticks_remaining: u64,
1849    #[serde(default)]
1850    pub ticks_total: u64,
1851}
1852
1853/// Cooldown state for a combat ability shown on the action bar.
1854#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1855pub struct AbilityCooldownHud {
1856    #[serde(default)]
1857    pub ability_id: String,
1858    #[serde(default)]
1859    pub label: String,
1860    #[serde(default)]
1861    pub cd_ticks: u64,
1862    #[serde(default)]
1863    pub cd_total_ticks: u64,
1864}
1865
1866/// One combat target slot in the observer HUD.
1867#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1868pub struct CombatSlotHud {
1869    pub slot_index: u8,
1870    #[serde(default)]
1871    pub target_entity_id: Option<EntityId>,
1872    #[serde(default)]
1873    pub target_label: Option<String>,
1874    #[serde(default)]
1875    pub target: Option<CombatTargetHud>,
1876    #[serde(default)]
1877    pub preset_id: Option<String>,
1878    #[serde(default)]
1879    pub preset_label: Option<String>,
1880    #[serde(default)]
1881    pub rotation: Vec<String>,
1882    #[serde(default)]
1883    pub rotation_index: u32,
1884    #[serde(default)]
1885    pub next_ability_id: Option<String>,
1886    #[serde(default)]
1887    pub auto_enabled: bool,
1888}
1889
1890/// One worn piece contribution in the defense breakdown.
1891#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1892pub struct DefensePieceHud {
1893    pub slot: BodySlot,
1894    pub label: String,
1895    pub template_id: String,
1896    #[serde(default)]
1897    pub armor_physical: f32,
1898    #[serde(default)]
1899    pub resists: Vec<(String, f32)>,
1900}
1901
1902impl Default for DefensePieceHud {
1903    fn default() -> Self {
1904        Self {
1905            slot: BodySlot::Head,
1906            label: String::new(),
1907            template_id: String::new(),
1908            armor_physical: 0.0,
1909            resists: Vec::new(),
1910        }
1911    }
1912}
1913
1914/// Aggregated mitigation / resist summary for the local player Equip UI.
1915#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1916pub struct DefenseHud {
1917    pub armor_physical: f32,
1918    pub vitality_contribution: f32,
1919    pub total_mitigation_rating: f32,
1920    /// `rating / (rating + K)` physical damage reduction fraction.
1921    pub estimated_physical_dr: f32,
1922    #[serde(default)]
1923    pub resists: Vec<(String, f32)>,
1924    #[serde(default)]
1925    pub pieces: Vec<DefensePieceHud>,
1926}
1927
1928/// Observer combat HUD (local player only).
1929#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1930pub struct CombatHud {
1931    pub in_combat: bool,
1932    /// T1 auto rotation (legacy field mirrors slot 1).
1933    pub auto_attack: bool,
1934    pub has_los: bool,
1935    pub attack_cd_ticks: u64,
1936    #[serde(default)]
1937    pub ability_id: String,
1938    #[serde(default)]
1939    pub target_entity_id: Option<EntityId>,
1940    #[serde(default)]
1941    pub target_label: Option<String>,
1942    #[serde(default)]
1943    pub max_target_slots: u8,
1944    #[serde(default)]
1945    pub slots: Vec<CombatSlotHud>,
1946    #[serde(default)]
1947    pub rotation_presets: Vec<RotationPreset>,
1948    #[serde(default)]
1949    pub gcd_ticks: u64,
1950    #[serde(default)]
1951    pub mainhand_template_id: Option<String>,
1952    #[serde(default)]
1953    pub mainhand_label: Option<String>,
1954    /// Specific mainhand instance when known (bindings / unique gear).
1955    #[serde(default)]
1956    pub mainhand_instance_id: Option<Uuid>,
1957    #[serde(default)]
1958    pub offhand_template_id: Option<String>,
1959    #[serde(default)]
1960    pub offhand_label: Option<String>,
1961    /// Specific offhand instance when known.
1962    #[serde(default)]
1963    pub offhand_instance_id: Option<Uuid>,
1964    /// Mainhand occupies this many hand sockets (`1` or `2`).
1965    #[serde(default)]
1966    pub mainhand_hand_slots: u8,
1967    /// Equipped body-slot items (backpack, belt, armor, jewelry).
1968    #[serde(default)]
1969    pub worn: Vec<(BodySlot, ItemStack)>,
1970    /// Live mitigation / resist summary for the Equip paperdoll.
1971    #[serde(default)]
1972    pub defense: Option<DefenseHud>,
1973    #[serde(default)]
1974    pub carry_mass: f32,
1975    #[serde(default)]
1976    pub carry_mass_max: f32,
1977    #[serde(default)]
1978    pub encumbrance: EncumbranceState,
1979    /// Keys on the virtual keychain (stowed, zero carry mass).
1980    #[serde(default)]
1981    pub keychain: Vec<ItemStack>,
1982    /// Paired whisper stones on the virtual pouch (stowed, zero carry mass).
1983    #[serde(default)]
1984    pub whisper_pouch: Vec<ItemStack>,
1985    #[serde(default)]
1986    pub target: Option<CombatTargetHud>,
1987    #[serde(default)]
1988    pub cast: Option<CastProgressHud>,
1989    /// Non-combat timed channel on the local player (till, plant, …).
1990    #[serde(default)]
1991    pub timed_channel: Option<TimedChannelHud>,
1992    /// When standing on an owned plot with no building: pad + material pool for the B menu.
1993    #[serde(default)]
1994    pub plot_build: Option<PlotBuildOfferHud>,
1995    #[serde(default)]
1996    pub ability_cooldowns: Vec<AbilityCooldownHud>,
1997    #[serde(default)]
1998    pub blocking_active: bool,
1999    /// Live XP pools for the local player (updated every tick with combat HUD).
2000    #[serde(default)]
2001    pub progression_xp: Option<ProgressionXp>,
2002    #[serde(default)]
2003    pub progression_baseline: u16,
2004    #[serde(default)]
2005    pub progression_xp_base: f64,
2006    #[serde(default)]
2007    pub progression_xp_growth: f64,
2008    #[serde(default)]
2009    pub attributes: Option<PrimaryAttributes>,
2010    #[serde(default)]
2011    pub skills: Option<PlayerSkills>,
2012    /// Active status effects on the local player.
2013    #[serde(default)]
2014    pub statuses: Vec<StatusEffectHud>,
2015    /// Learned abilities currently usable (permanent + unexpired temporary).
2016    #[serde(default)]
2017    pub known_abilities: Vec<String>,
2018    /// Aim / blast metadata for known abilities (ground cast UX).
2019    #[serde(default)]
2020    pub ability_meta: Vec<AbilityMetaHud>,
2021    /// Per-ability mastery for known abilities (tier / XP).
2022    #[serde(default)]
2023    pub ability_mastery: Vec<AbilityMasteryHud>,
2024    /// Hotbar bindings for keys 1–9 (index 0 = key 1).
2025    /// Ability ids, or `item:<template_id>` for consumables ([`hotbar_consumable_binding`]).
2026    #[serde(default)]
2027    pub hotbar: Vec<Option<String>>,
2028    /// Max abilities allowed in one rotation (INT+WIS mind score).
2029    #[serde(default)]
2030    pub max_abilities_per_rotation: u8,
2031    /// Effective walk speed after encumbrance, DEX, and status (m/s). Not sneak/sprint.
2032    #[serde(default)]
2033    pub move_speed_mps: f32,
2034    /// Load/status multiplier on base walk (`1.0` = unencumbered). `0` = unknown.
2035    #[serde(default)]
2036    pub move_speed_mult: f32,
2037}
2038
2039/// Client-facing ability aiming hints (synced on combat HUD).
2040#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2041pub struct AbilityMetaHud {
2042    pub id: String,
2043    /// `entity` | `ground` | `either`
2044    #[serde(default = "default_aim_mode_entity")]
2045    pub aim_mode: String,
2046    #[serde(default)]
2047    pub blast_radius_m: f32,
2048    #[serde(default)]
2049    pub allows_self: bool,
2050    #[serde(default)]
2051    pub is_heal: bool,
2052    /// When false, rotation editor / presets should not include this ability
2053    /// (hotbar / direct cast still allowed). Default true for older snapshots.
2054    #[serde(default = "default_auto_rotation_eligible")]
2055    pub auto_rotation_eligible: bool,
2056}
2057
2058fn default_auto_rotation_eligible() -> bool {
2059    true
2060}
2061
2062fn default_aim_mode_entity() -> String {
2063    "entity".into()
2064}
2065
2066/// Prefix for hotbar slots bound to inventory consumables (`item:health_potion`).
2067pub const HOTBAR_ITEM_PREFIX: &str = "item:";
2068
2069/// Encode a consumable template id for [`Intent::SetHotbarSlot`] / combat HUD hotbar.
2070pub fn hotbar_consumable_binding(template_id: &str) -> String {
2071    format!("{HOTBAR_ITEM_PREFIX}{}", template_id.trim())
2072}
2073
2074/// If `binding` is an `item:` consumable slot, return the template id.
2075pub fn hotbar_consumable_template(binding: &str) -> Option<&str> {
2076    binding
2077        .strip_prefix(HOTBAR_ITEM_PREFIX)
2078        .map(str::trim)
2079        .filter(|id| !id.is_empty())
2080}
2081
2082/// True when a hotbar binding string refers to a consumable (not an ability).
2083pub fn hotbar_binding_is_consumable(binding: &str) -> bool {
2084    hotbar_consumable_template(binding).is_some()
2085}
2086
2087/// Spatial combat cue for map overlays (`plans/39`).
2088#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2089#[serde(rename_all = "snake_case")]
2090pub enum CombatFxKind {
2091    MeleeArc,
2092    Cone,
2093    Sphere,
2094    Beam,
2095    HitMarker,
2096}
2097
2098/// Outcome attached to a hit marker / struck entity.
2099#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2100#[serde(rename_all = "snake_case")]
2101pub enum CombatFxHitOutcome {
2102    #[default]
2103    Hit,
2104    Blocked,
2105    Miss,
2106    Glance,
2107}
2108
2109/// One entity struck (or targeted) by a combat FX resolve.
2110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2111pub struct CombatFxHit {
2112    pub entity_id: EntityId,
2113    pub x: f32,
2114    pub y: f32,
2115    pub z: f32,
2116    #[serde(default)]
2117    pub outcome: CombatFxHitOutcome,
2118}
2119
2120/// Authoritative attack footprint / hit cue for clients to draw on the map.
2121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2122pub struct CombatFx {
2123    pub id: u64,
2124    pub kind: CombatFxKind,
2125    pub ability_id: String,
2126    pub caster_id: EntityId,
2127    pub origin_x: f32,
2128    pub origin_y: f32,
2129    pub origin_z: f32,
2130    #[serde(default)]
2131    pub end_x: Option<f32>,
2132    #[serde(default)]
2133    pub end_y: Option<f32>,
2134    #[serde(default)]
2135    pub end_z: Option<f32>,
2136    #[serde(default)]
2137    pub yaw: Option<f32>,
2138    #[serde(default)]
2139    pub reach_m: Option<f32>,
2140    #[serde(default)]
2141    pub arc_deg: Option<f32>,
2142    #[serde(default)]
2143    pub radius_m: Option<f32>,
2144    #[serde(default)]
2145    pub hits: Vec<CombatFxHit>,
2146    /// Sim tick after which clients should drop this cue.
2147    pub until_tick: u64,
2148    #[serde(default)]
2149    pub damage_type: String,
2150}
2151
2152/// Lingering ground hazard (boss `leave_hazard` puddle) visible until `expires_at_tick`.
2153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2154pub struct GroundHazardView {
2155    pub x: f32,
2156    pub y: f32,
2157    pub z: f32,
2158    pub radius_m: f32,
2159    pub expires_at_tick: u64,
2160    #[serde(default)]
2161    pub damage_type: String,
2162}
2163
2164/// Hired worker automation mode on the wire.
2165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2166#[serde(rename_all = "snake_case")]
2167pub enum WorkerModeView {
2168    Companion,
2169    Defender,
2170    JobLoop,
2171    /// Deliberately parked — no route, no follow. The worker stands down
2172    /// (still draws wages) until the employer assigns a mode/route again.
2173    Idle,
2174}
2175
2176/// Hired worker FSM state on the wire.
2177#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2178#[serde(rename_all = "snake_case")]
2179pub enum WorkerStateView {
2180    Idle,
2181    Traveling,
2182    Working,
2183    Resting,
2184    Waiting,
2185    Strike,
2186    Dismissed,
2187}
2188
2189/// Compact vitals for hired worker HUD rows and proximity overlay.
2190#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
2191pub struct WorkerVitalsSummary {
2192    pub health_pct: f32,
2193    pub stamina_pct: f32,
2194    #[serde(default)]
2195    pub mana_pct: f32,
2196    #[serde(default)]
2197    pub hunger_pct: f32,
2198    #[serde(default)]
2199    pub thirst_pct: f32,
2200}
2201
2202/// High-level route shape — `harvest_loop` (legacy flat lists) or `ordered` (typed stop list).
2203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2204#[serde(rename_all = "snake_case")]
2205pub enum WorkerRouteKindView {
2206    #[default]
2207    HarvestLoop,
2208    Ordered,
2209}
2210
2211/// Saved worker route for UI / route editor reload.
2212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
2213pub struct WorkerRouteView {
2214    #[serde(default)]
2215    pub kind: WorkerRouteKindView,
2216    #[serde(default)]
2217    pub lodging_container_id: Option<String>,
2218    /// Legacy `harvest_loop` waypoint list.
2219    #[serde(default)]
2220    pub outbound_waypoints: Vec<WorkerRouteWaypointView>,
2221    /// Legacy `harvest_loop` node id list.
2222    #[serde(default)]
2223    pub harvest_nodes: Vec<String>,
2224    #[serde(default = "default_route_carry_ratio")]
2225    pub carry_return_ratio: f32,
2226    /// Ordered-route typed stops (`kind: ordered`).
2227    #[serde(default)]
2228    pub stops: Vec<WorkerRouteStopView>,
2229}
2230
2231fn default_route_carry_ratio() -> f32 {
2232    0.90
2233}
2234
2235fn default_true_view() -> bool {
2236    true
2237}
2238
2239/// One withdraw line for a `WithdrawFrom` route stop view.
2240#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2241pub struct WorkerWithdrawItemView {
2242    pub template: String,
2243    /// Hold-up-to count (top-up each loop). Ignored when `all` is set.
2244    #[serde(default)]
2245    pub qty: u32,
2246    /// Take every stack of this template (carry-capped). Wins over `qty`.
2247    #[serde(default)]
2248    pub all: bool,
2249}
2250
2251#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
2252pub struct WorkerRouteWaypointView {
2253    pub x: f32,
2254    pub y: f32,
2255    pub z: f32,
2256}
2257
2258/// One typed stop in an ordered worker route.
2259///
2260/// NOTE: this is the wire (postcard) view. Postcard does **not** support
2261/// internally-tagged enums (`#[serde(tag = ...)]` — it returns `WontImplement`
2262/// from `deserialize_any`), so this enum uses serde's default **external tagging**.
2263/// The sim-side `WorkerRouteStop` (`crates/sim/src/worker_job.rs`) is the YAML-facing
2264/// twin and keeps its `tag = "stop"` for human-authored job YAML; the two never share
2265/// a wire format.
2266#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2267#[serde(rename_all = "snake_case")]
2268pub enum WorkerRouteStopView {
2269    Waypoint {
2270        x: f32,
2271        y: f32,
2272        #[serde(default)]
2273        z: f32,
2274    },
2275    HarvestNode {
2276        node_id: String,
2277    },
2278    DepositAt {
2279        container_id: String,
2280        #[serde(default)]
2281        filter: Option<Vec<String>>,
2282    },
2283    TradeWith {
2284        #[serde(default)]
2285        npc_id: Option<String>,
2286        template: String,
2287        #[serde(default = "default_true_view")]
2288        sell_all: bool,
2289    },
2290    /// List held stacks on a market hall at NPC price (dump queue).
2291    ListOnMarket {
2292        template: String,
2293        #[serde(default = "default_true_view")]
2294        list_all: bool,
2295        #[serde(default)]
2296        hall_id: Option<String>,
2297    },
2298    WithdrawFrom {
2299        container_id: String,
2300        items: Vec<WorkerWithdrawItemView>,
2301    },
2302    CraftAt {
2303        device: String,
2304        blueprint: String,
2305        #[serde(default)]
2306        qty: Option<u32>,
2307    },
2308    CultivatePlot {
2309        plot_id: uuid::Uuid,
2310    },
2311    PlantPlot {
2312        plot_id: uuid::Uuid,
2313        seed_template: String,
2314    },
2315    HarvestPlot {
2316        plot_id: uuid::Uuid,
2317    },
2318    RestIfNeeded,
2319    Wait {
2320        #[serde(default)]
2321        wait_ticks: u64,
2322    },
2323}
2324
2325/// Copper ledger category (expense negative / income positive on the wire entry).
2326#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2327#[serde(rename_all = "snake_case")]
2328pub enum LedgerCategory {
2329    Workers,
2330    Hire,
2331    Train,
2332    ShopBuy,
2333    Taxes,
2334    WorkerSales,
2335    TraderSales,
2336    BankDeposit,
2337    BankWithdraw,
2338    BankTransferOut,
2339    BankTransferIn,
2340    BankTransferFee,
2341    StorageShipFee,
2342    /// Crown or private purchase of a property deed.
2343    PropertyBuy,
2344    /// Crown buyback or private sale of a property deed.
2345    PropertySell,
2346    /// Landlord share of harvest tax paid on an owned plot.
2347    TaxShare,
2348    /// Bank debit for a market-hall purchase (`plans/10-economy-and-markets.md`).
2349    MarketBuy,
2350    /// Bank credit for a market-hall sale (net of crown sales tax).
2351    MarketSell,
2352    Other,
2353}
2354
2355impl LedgerCategory {
2356    pub fn as_str(self) -> &'static str {
2357        match self {
2358            Self::Workers => "workers",
2359            Self::Hire => "hire",
2360            Self::Train => "train",
2361            Self::ShopBuy => "shop_buy",
2362            Self::Taxes => "taxes",
2363            Self::WorkerSales => "worker_sales",
2364            Self::TraderSales => "trader_sales",
2365            Self::BankDeposit => "bank_deposit",
2366            Self::BankWithdraw => "bank_withdraw",
2367            Self::BankTransferOut => "bank_transfer_out",
2368            Self::BankTransferIn => "bank_transfer_in",
2369            Self::BankTransferFee => "bank_transfer_fee",
2370            Self::StorageShipFee => "storage_ship_fee",
2371            Self::PropertyBuy => "property_buy",
2372            Self::PropertySell => "property_sell",
2373            Self::TaxShare => "tax_share",
2374            Self::MarketBuy => "market_buy",
2375            Self::MarketSell => "market_sell",
2376            Self::Other => "other",
2377        }
2378    }
2379}
2380
2381#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2382pub struct LedgerEntryView {
2383    pub id: uuid::Uuid,
2384    pub game_day: u64,
2385    pub signed_copper: i64,
2386    pub category: LedgerCategory,
2387    #[serde(default)]
2388    pub label: String,
2389}
2390
2391#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
2392pub struct LedgerPeriodTotals {
2393    /// Absolute copper spent by category key.
2394    #[serde(default)]
2395    pub expenses: std::collections::HashMap<String, u64>,
2396    /// Absolute copper earned by category key.
2397    #[serde(default)]
2398    pub income: std::collections::HashMap<String, u64>,
2399    pub expense_copper: u64,
2400    pub income_copper: u64,
2401    /// income − expenses (may be negative).
2402    pub cash_flow_copper: i64,
2403}
2404
2405#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
2406pub struct PlayerLedgerView {
2407    pub current_game_day: u64,
2408    #[serde(default)]
2409    pub period_day: LedgerPeriodTotals,
2410    #[serde(default)]
2411    pub period_week: LedgerPeriodTotals,
2412    #[serde(default)]
2413    pub period_month: LedgerPeriodTotals,
2414    #[serde(default)]
2415    pub period_lifetime: LedgerPeriodTotals,
2416    #[serde(default)]
2417    pub recent: Vec<LedgerEntryView>,
2418    /// Copper on the character (inventory + worn bags).
2419    #[serde(default)]
2420    pub wealth_on_person_copper: u64,
2421    /// Copper in owned placed storage (chests, lodging — not bank ledger).
2422    #[serde(default)]
2423    pub wealth_in_storage_copper: u64,
2424    /// Copper in the secure bank ledger (`plans/08` §8).
2425    #[serde(default)]
2426    pub wealth_in_bank_copper: u64,
2427    /// `wealth_on_person_copper + wealth_in_storage_copper + wealth_in_bank_copper`.
2428    #[serde(default)]
2429    pub wealth_total_copper: u64,
2430    /// Sum of purchase-basis copper for deeds this character currently holds (asset book value).
2431    #[serde(default)]
2432    pub wealth_in_property_copper: u64,
2433    /// Liquid copper + property book value.
2434    #[serde(default)]
2435    pub wealth_net_worth_copper: u64,
2436    /// Deeds held (inventory, worn bags, town vault, owned chests) with book values.
2437    #[serde(default)]
2438    pub property_assets: Vec<PropertyAssetView>,
2439    /// Recent property sales near plots you hold (comps for a local market).
2440    #[serde(default)]
2441    pub property_market_nearby: Vec<PropertyMarketCompView>,
2442    /// Live payroll burn (cp per wage interval) for hired workers.
2443    #[serde(default)]
2444    pub live_expense_per_interval_copper: u64,
2445    /// Estimated copper from one full worker job loop (broker sell steps).
2446    #[serde(default)]
2447    pub live_income_route_est_per_loop_copper: u64,
2448    /// Recent average income (worker/trader sales) per wage interval.
2449    #[serde(default)]
2450    pub live_income_avg_per_interval_copper: u64,
2451    /// Number of wage intervals in the rolling average window.
2452    #[serde(default)]
2453    pub live_income_avg_window_intervals: u32,
2454    /// `live_income_avg_per_interval_copper − live_expense_per_interval_copper`.
2455    #[serde(default)]
2456    pub live_net_avg_per_interval_copper: i64,
2457}
2458
2459/// One deed currently held by the character (ledger asset line).
2460#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2461pub struct PropertyAssetView {
2462    pub plot_id: Uuid,
2463    /// Friendly deed label (`{Owner}'s {Zone} deed @ (x, y) (N m²)`).
2464    pub label: String,
2465    pub zone_id: String,
2466    #[serde(default)]
2467    pub zone_label: Option<String>,
2468    pub area_m2: f32,
2469    /// Book value (what you paid / last private sale price).
2470    pub purchase_basis_copper: u64,
2471    pub upkeep_copper_per_day: u64,
2472}
2473
2474/// A recorded property sale near the observer's holdings.
2475#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2476pub struct PropertyMarketCompView {
2477    pub day: u64,
2478    pub zone_id: String,
2479    #[serde(default)]
2480    pub zone_label: Option<String>,
2481    pub area_m2: f32,
2482    pub price_copper: u64,
2483    /// `price_copper / area_m2` (0 when area is tiny).
2484    pub price_per_m2_copper: u64,
2485    /// `crown_purchase` | `crown_buyback` | `player_trade`.
2486    pub kind: String,
2487    /// Distance from the nearest plot you hold (meters).
2488    pub distance_m: f32,
2489}
2490
2491/// Gameplay analytics metric keys (extensible string on the wire via rename).
2492#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2493#[serde(rename_all = "snake_case")]
2494pub enum AnalyticsMetric {
2495    NpcKill,
2496    WildlifeKill,
2497    Harvest,
2498    QuestComplete,
2499    QuestAccept,
2500    QuestAbandon,
2501    PlayerDeath,
2502    Craft,
2503    WorkerHire,
2504    WorkerDismiss,
2505    WorkerTeach,
2506    NpcTalk,
2507    ShopBuy,
2508    ShopSell,
2509    PlaceContainer,
2510    PickupContainer,
2511    PickupDrop,
2512    ConsumableUse,
2513    AbilityUse,
2514    DistanceWalkedM,
2515    DoorUse,
2516    BuildingEnter,
2517}
2518
2519impl AnalyticsMetric {
2520    pub fn as_str(self) -> &'static str {
2521        match self {
2522            Self::NpcKill => "npc_kill",
2523            Self::WildlifeKill => "wildlife_kill",
2524            Self::Harvest => "harvest",
2525            Self::QuestComplete => "quest_complete",
2526            Self::QuestAccept => "quest_accept",
2527            Self::QuestAbandon => "quest_abandon",
2528            Self::PlayerDeath => "player_death",
2529            Self::Craft => "craft",
2530            Self::WorkerHire => "worker_hire",
2531            Self::WorkerDismiss => "worker_dismiss",
2532            Self::WorkerTeach => "worker_teach",
2533            Self::NpcTalk => "npc_talk",
2534            Self::ShopBuy => "shop_buy",
2535            Self::ShopSell => "shop_sell",
2536            Self::PlaceContainer => "place_container",
2537            Self::PickupContainer => "pickup_container",
2538            Self::PickupDrop => "pickup_drop",
2539            Self::ConsumableUse => "consumable_use",
2540            Self::AbilityUse => "ability_use",
2541            Self::DistanceWalkedM => "distance_walked_m",
2542            Self::DoorUse => "door_use",
2543            Self::BuildingEnter => "building_enter",
2544        }
2545    }
2546
2547    pub fn from_str_key(s: &str) -> Option<Self> {
2548        Some(match s {
2549            "npc_kill" => Self::NpcKill,
2550            "wildlife_kill" => Self::WildlifeKill,
2551            "harvest" => Self::Harvest,
2552            "quest_complete" => Self::QuestComplete,
2553            "quest_accept" => Self::QuestAccept,
2554            "quest_abandon" => Self::QuestAbandon,
2555            "player_death" => Self::PlayerDeath,
2556            "craft" => Self::Craft,
2557            "worker_hire" => Self::WorkerHire,
2558            "worker_dismiss" => Self::WorkerDismiss,
2559            "worker_teach" => Self::WorkerTeach,
2560            "npc_talk" => Self::NpcTalk,
2561            "shop_buy" => Self::ShopBuy,
2562            "shop_sell" => Self::ShopSell,
2563            "place_container" => Self::PlaceContainer,
2564            "pickup_container" => Self::PickupContainer,
2565            "pickup_drop" => Self::PickupDrop,
2566            "consumable_use" => Self::ConsumableUse,
2567            "ability_use" => Self::AbilityUse,
2568            "distance_walked_m" => Self::DistanceWalkedM,
2569            "door_use" => Self::DoorUse,
2570            "building_enter" => Self::BuildingEnter,
2571            _ => return None,
2572        })
2573    }
2574}
2575
2576#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2577pub struct CareerMetricRow {
2578    pub subject_id: String,
2579    pub amount: u64,
2580}
2581
2582/// Personal analytics summary for the Character `i` Career tab.
2583#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
2584pub struct PlayerCareerView {
2585    pub current_game_day: u64,
2586    #[serde(default)]
2587    pub kills: Vec<CareerMetricRow>,
2588    #[serde(default)]
2589    pub harvests: Vec<CareerMetricRow>,
2590    pub quests_completed: u64,
2591    #[serde(default)]
2592    pub crafts: Vec<CareerMetricRow>,
2593    pub deaths: u64,
2594    pub npc_talks: u64,
2595    pub shop_buys: u64,
2596    pub shop_sells: u64,
2597    pub distance_m: u64,
2598    #[serde(default)]
2599    pub other: Vec<CareerMetricRow>,
2600}
2601
2602/// Equipment currently worn by a player-hired worker.
2603///
2604/// Equipped items are separate from `HiredWorkerView::inventory`; the latter
2605/// contains only the worker's loose/root pack contents.
2606#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
2607pub struct WorkerEquipmentView {
2608    #[serde(default)]
2609    pub mainhand: Option<ItemStack>,
2610    #[serde(default)]
2611    pub offhand: Option<ItemStack>,
2612    #[serde(default)]
2613    pub worn: Vec<(BodySlot, ItemStack)>,
2614}
2615
2616/// One player-hired worker visible in snapshot / tick deltas.
2617#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2618pub struct HiredWorkerView {
2619    pub instance_id: String,
2620    pub entity_id: EntityId,
2621    pub def_id: String,
2622    /// Display label (custom name when set, otherwise the NPC def label).
2623    pub label: String,
2624    pub x: f32,
2625    pub y: f32,
2626    pub z: f32,
2627    pub mode: WorkerModeView,
2628    pub state: WorkerStateView,
2629    #[serde(default)]
2630    pub step_label: String,
2631    pub vitals: WorkerVitalsSummary,
2632    #[serde(default)]
2633    pub carry_pct: f32,
2634    #[serde(default)]
2635    pub last_error: Option<String>,
2636    pub wage_copper_per_interval: u32,
2637    /// Estimated wage this interval (base + loop effort, travel meters excluded).
2638    #[serde(default)]
2639    pub effective_wage_copper: u32,
2640    /// Meters walked toward the next wage debit.
2641    #[serde(default)]
2642    pub wage_meters_walked: f32,
2643    /// Placed camp bed / lodging container this worker uses for deposit and rest.
2644    #[serde(default)]
2645    pub lodging_container_id: Option<String>,
2646    /// High-level harvest route (when job_loop route is configured).
2647    #[serde(default)]
2648    pub route: Option<WorkerRouteView>,
2649    /// Index into `route.stops` for the worker's current job step (ordered routes).
2650    /// Maps expanded job steps (travel+deposit, etc.) back to the designer stop.
2651    #[serde(default)]
2652    pub route_stop_index: Option<u32>,
2653    /// Recipes this worker already knows (from hire `teaches` + employer teach).
2654    #[serde(default)]
2655    pub known_blueprint_ids: Vec<String>,
2656    /// Overall worker level (1+).
2657    #[serde(default = "default_worker_view_level")]
2658    pub level: u32,
2659    /// Cumulative worker XP.
2660    #[serde(default)]
2661    pub worker_xp: f64,
2662    /// Items the worker currently carries (employer-visible for give/take).
2663    #[serde(default)]
2664    pub inventory: Vec<ItemStack>,
2665    /// Items currently equipped by the worker, separate from `inventory`.
2666    #[serde(default)]
2667    pub equipment: WorkerEquipmentView,
2668    /// Short "what you should do" line when `last_error` is a player-actionable
2669    /// plan or logistics issue (missing chest, empty lodging, etc.).
2670    #[serde(default)]
2671    pub issue_hint: Option<String>,
2672    /// True when the worker is job-blocked (missing tool, all harvest nodes
2673    /// unreachable) and should show a roster red dot.
2674    #[serde(default)]
2675    pub has_blocking_issue: bool,
2676    /// Harvest nodes skipped because there is no path (depleted nodes omitted).
2677    #[serde(default)]
2678    pub harvest_node_issues: Vec<WorkerHarvestNodeIssueView>,
2679}
2680
2681/// One harvest node the worker could not path to (work-list red row).
2682#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2683pub struct WorkerHarvestNodeIssueView {
2684    pub node_id: String,
2685    pub issue: String,
2686}
2687
2688fn default_worker_view_level() -> u32 {
2689    1
2690}
2691
2692/// Server → client AOI-filtered entity updates for one sim tick.
2693#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2694pub struct TickDelta {
2695    pub tick: Tick,
2696    pub entities: Vec<EntityState>,
2697    #[serde(default)]
2698    pub resource_nodes: Vec<ResourceNodeView>,
2699    #[serde(default)]
2700    pub buildings: Vec<BuildingView>,
2701    #[serde(default)]
2702    pub doors: Vec<DoorView>,
2703    #[serde(default)]
2704    pub npcs: Vec<NpcView>,
2705    /// Observer inventory stacks (template → qty).
2706    #[serde(default)]
2707    pub inventory: Vec<ItemStack>,
2708    #[serde(default)]
2709    pub blueprints: Vec<BlueprintView>,
2710    /// Wall/roof material packs for the B plot-build menu.
2711    #[serde(default)]
2712    pub building_materials: Vec<BuildingMaterialView>,
2713    #[serde(default)]
2714    pub world_clock: WorldClock,
2715    #[serde(default)]
2716    pub ground_drops: Vec<GroundDropView>,
2717    #[serde(default)]
2718    pub placed_containers: Vec<PlacedContainerView>,
2719    #[serde(default)]
2720    pub combat: Option<CombatHud>,
2721    #[serde(default)]
2722    pub interior_map: Option<InteriorMapView>,
2723    #[serde(default)]
2724    pub quest_log: Vec<QuestLogEntry>,
2725    #[serde(default)]
2726    pub hired_workers: Vec<HiredWorkerView>,
2727    #[serde(default)]
2728    pub interactables: Vec<InteractableView>,
2729    #[serde(default)]
2730    pub ledger: Option<PlayerLedgerView>,
2731    #[serde(default)]
2732    pub career: Option<PlayerCareerView>,
2733    /// Active combat footprints / hit markers in observer AOI (`plans/39`).
2734    #[serde(default)]
2735    pub combat_fx: Vec<CombatFx>,
2736    /// Persistent ground hazards (boss puddles) in observer AOI.
2737    #[serde(default)]
2738    pub ground_hazards: Vec<GroundHazardView>,
2739    /// Claimed property plots near the observer (plan 40).
2740    #[serde(default)]
2741    pub property_plots: Vec<PropertyPlotView>,
2742    /// Runtime terrain cell overlays (cultivate dirt → tilled). Replaces prior overlays.
2743    #[serde(default)]
2744    pub terrain_overlays: Vec<TerrainZoneView>,
2745}
2746#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2747pub struct GroundDropView {
2748    pub id: String,
2749    pub template_id: String,
2750    pub quantity: u32,
2751    pub x: f32,
2752    pub y: f32,
2753    pub z: f32,
2754    /// Optional gfx tile id from the item template.
2755    #[serde(default)]
2756    pub tile_id: Option<String>,
2757    /// Item display name (e.g. "Cloth Pants") for hover/chat labels.
2758    #[serde(default)]
2759    pub display_name: Option<String>,
2760    /// Facing yaw in radians (0 = north) for sprite draw on the ground.
2761    #[serde(default)]
2762    pub yaw: f32,
2763    /// Pitch in radians (0 = upright).
2764    #[serde(default)]
2765    pub pitch: f32,
2766    /// Roll in radians (0 = upright; tilts in the view plane).
2767    #[serde(default)]
2768    pub roll: f32,
2769    /// Draw scale relative to one map cell (1.0 = cell size).
2770    #[serde(default = "default_draw_scale")]
2771    pub draw_scale: f32,
2772    /// Preserved unique instance id when the pile carries full slot state (`plans/18`).
2773    #[serde(default)]
2774    pub item_instance_id: Option<Uuid>,
2775    /// Instance props (custom name, deed metadata, etc.).
2776    #[serde(default)]
2777    pub props: std::collections::BTreeMap<String, String>,
2778    /// Enchant / status bindings on the unique instance.
2779    #[serde(default)]
2780    pub status_bindings: Vec<ItemStatusBinding>,
2781}
2782
2783fn default_draw_scale() -> f32 {
2784    1.0
2785}
2786
2787/// Full state on region enter or reconnect.
2788#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2789pub struct Snapshot {
2790    pub tick: Tick,
2791    pub chunk_rev: u64,
2792    /// Bumped when blueprints, catalog, segment, or settings reload.
2793    #[serde(default)]
2794    pub content_rev: u64,
2795    /// Stable publish revision from `assets/.content-publish.json` (for client asset sync).
2796    #[serde(default)]
2797    pub publish_rev: u64,
2798    pub entities: Vec<EntityState>,
2799    #[serde(default)]
2800    pub resource_nodes: Vec<ResourceNodeView>,
2801    /// Outdoor play AABB origin (meters). With width/height, defines the composed world.
2802    #[serde(default)]
2803    pub world_x0: f32,
2804    #[serde(default)]
2805    pub world_y0: f32,
2806    /// Segment play area width in meters (for HUD / beyond-zone).
2807    #[serde(default)]
2808    pub world_width_m: f32,
2809    #[serde(default)]
2810    pub world_height_m: f32,
2811    #[serde(default)]
2812    pub buildings: Vec<BuildingView>,
2813    #[serde(default)]
2814    pub doors: Vec<DoorView>,
2815    #[serde(default)]
2816    pub npcs: Vec<NpcView>,
2817    #[serde(default)]
2818    pub inventory: Vec<ItemStack>,
2819    #[serde(default)]
2820    pub blueprints: Vec<BlueprintView>,
2821    /// Wall/roof material packs for the B plot-build menu.
2822    #[serde(default)]
2823    pub building_materials: Vec<BuildingMaterialView>,
2824    #[serde(default)]
2825    pub world_clock: WorldClock,
2826    #[serde(default)]
2827    pub terrain_zones: Vec<TerrainZoneView>,
2828    #[serde(default)]
2829    pub z_platforms: Vec<ZPlatformView>,
2830    #[serde(default)]
2831    pub z_transitions: Vec<ZTransitionView>,
2832    #[serde(default)]
2833    pub ground_drops: Vec<GroundDropView>,
2834    #[serde(default)]
2835    pub placed_containers: Vec<PlacedContainerView>,
2836    #[serde(default)]
2837    pub combat: Option<CombatHud>,
2838    #[serde(default)]
2839    pub interior_map: Option<InteriorMapView>,
2840    #[serde(default)]
2841    pub quest_log: Vec<QuestLogEntry>,
2842    #[serde(default)]
2843    pub hired_workers: Vec<HiredWorkerView>,
2844    #[serde(default)]
2845    pub interactables: Vec<InteractableView>,
2846    #[serde(default)]
2847    pub ledger: Option<PlayerLedgerView>,
2848    #[serde(default)]
2849    pub career: Option<PlayerCareerView>,
2850    /// Active combat footprints / hit markers (`plans/39`).
2851    #[serde(default)]
2852    pub combat_fx: Vec<CombatFx>,
2853    /// Persistent ground hazards (boss puddles) in observer AOI.
2854    #[serde(default)]
2855    pub ground_hazards: Vec<GroundHazardView>,
2856    /// Authored crown property zones (claimable land) — plan 40.
2857    #[serde(default)]
2858    pub property_zones: Vec<PropertyZoneView>,
2859    /// Tax overlays (for claim cost premium preview).
2860    #[serde(default)]
2861    pub tax_zones: Vec<TaxZoneView>,
2862    /// Town / security / PvP boundaries (plan 43).
2863    #[serde(default)]
2864    pub boundary_zones: Vec<BoundaryZoneView>,
2865    /// Random-encounter rectangles (plan 54).
2866    #[serde(default)]
2867    pub encounter_zones: Vec<EncounterZoneView>,
2868    /// Growth / fertility overlays (plan 37 / farming).
2869    #[serde(default)]
2870    pub growth_zones: Vec<GrowthZoneView>,
2871    /// Climate / biome overlays (plan 38).
2872    #[serde(default)]
2873    pub biome_zones: Vec<BiomeZoneView>,
2874    /// Terrain kind move speed / impassable table for client pathfinding (from terrain-kinds.yaml).
2875    #[serde(default)]
2876    pub terrain_kind_nav: Vec<TerrainKindNavView>,
2877    /// Claimed property plots in this segment.
2878    #[serde(default)]
2879    pub property_plots: Vec<PropertyPlotView>,
2880    /// Server knobs needed for client claim quote preview.
2881    #[serde(default)]
2882    pub property_plot_settings: Option<PropertyPlotSettingsView>,
2883    /// Compact item catalog (labels + category) so pickers can resolve UUID
2884    /// template ids without relying on inventory-only hints.
2885    #[serde(default)]
2886    pub item_catalog: Vec<ItemCatalogEntryView>,
2887}
2888
2889/// Compact catalog row for client pickers (not a full item def).
2890#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2891pub struct ItemCatalogEntryView {
2892    pub template_id: String,
2893    #[serde(default)]
2894    pub display_name: String,
2895    #[serde(default)]
2896    pub category: String,
2897    /// When set, this template is a farm seed for the named crop.
2898    #[serde(default)]
2899    pub seed_for: Option<String>,
2900}
2901
2902impl ItemCatalogEntryView {
2903    pub fn is_harvest_node(&self) -> bool {
2904        self.category == "harvest_node"
2905    }
2906
2907    /// World harvest-node templates cannot exist as deposit/inventory stacks.
2908    pub fn is_depositable_stack(&self) -> bool {
2909        !self.is_harvest_node()
2910    }
2911
2912    pub fn is_farm_seed(&self) -> bool {
2913        self.seed_for
2914            .as_deref()
2915            .is_some_and(|s| !s.trim().is_empty())
2916            || self.category == "seed"
2917    }
2918}
2919
2920/// Harvestable node visible to clients.
2921#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2922pub struct ResourceNodeView {
2923    pub id: String,
2924    pub label: String,
2925    pub x: f32,
2926    pub y: f32,
2927    pub z: f32,
2928    pub item_template: String,
2929    #[serde(default = "default_node_state")]
2930    pub state: ResourceNodeState,
2931    /// When true, players cannot walk through this node while available/harvesting.
2932    #[serde(default = "default_blocking_view")]
2933    pub blocking: bool,
2934    /// Collision radius for pathfinding (meters).
2935    #[serde(default = "default_blocking_radius_view")]
2936    pub blocking_radius_m: f32,
2937    /// Decorative map placement — not harvestable; `blocking` is forced false on the wire.
2938    #[serde(default)]
2939    pub harvest_off: bool,
2940    /// Optional gfx tile id (`resource.oak_log`, …).
2941    #[serde(default)]
2942    pub tile_id: Option<String>,
2943    /// Facing yaw in radians (0 = north). From map placement.
2944    #[serde(default)]
2945    pub yaw: f32,
2946    /// Pitch in radians (0 = upright). From map placement.
2947    #[serde(default)]
2948    pub pitch: f32,
2949    /// Roll in radians (0 = upright). From map placement.
2950    #[serde(default)]
2951    pub roll: f32,
2952    /// Draw scale relative to one map cell (1.0 = cell size).
2953    #[serde(default = "default_draw_scale")]
2954    pub draw_scale: f32,
2955    /// Resolved gfx sprite mode for `tile_id` (server-computed).
2956    #[serde(default)]
2957    pub sprite_mode: Option<String>,
2958    /// Canonical presentation key (`available`, `harvesting`, `depleted`).
2959    #[serde(default)]
2960    pub presentation_state: Option<String>,
2961    /// Farm crop growth 0.0–1.0 while immature; `None` for other nodes and mature crops.
2962    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
2963    #[serde(default)]
2964    pub growth_progress: Option<f32>,
2965    /// Active harvest channel (sim ticks), for progress rings on the node.
2966    #[serde(default)]
2967    pub channel_start_tick: Option<Tick>,
2968    #[serde(default)]
2969    pub channel_end_tick: Option<Tick>,
2970    /// Possible loot templates from this node's harvest loot table (route deposit filters).
2971    #[serde(default)]
2972    pub harvest_drop_templates: Vec<String>,
2973}
2974
2975fn default_blocking_radius_view() -> f32 {
2976    0.8
2977}
2978
2979fn default_blocking_view() -> bool {
2980    true
2981}
2982
2983#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2984#[serde(rename_all = "snake_case")]
2985pub enum ResourceNodeState {
2986    Available,
2987    Harvesting,
2988    Cooldown,
2989}
2990fn default_node_state() -> ResourceNodeState {
2991    ResourceNodeState::Available
2992}
2993
2994/// Live state of a placed world item spawn (plan 45).
2995#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2996#[serde(rename_all = "snake_case")]
2997pub enum ItemSpawnStateView {
2998    Spawned,
2999    PickedUp { respawn_at_tick: u64 },
3000    Consumed,
3001}
3002
3003/// Authoritative view of a placed findable item (plan 45) for admin/overseer.
3004#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3005pub struct ItemSpawnView {
3006    pub id: String,
3007    pub label: String,
3008    pub item_template: String,
3009    pub quantity: u32,
3010    pub x: f32,
3011    pub y: f32,
3012    pub z: f32,
3013    pub respawn_ticks: u32,
3014    #[serde(default)]
3015    pub building_id: Option<String>,
3016    pub state: ItemSpawnStateView,
3017    /// Each character may pick this spawn once; the pile stays for others.
3018    #[serde(default)]
3019    pub once_per_character: bool,
3020    /// How many characters have already collected a once-per-character spawn.
3021    #[serde(default)]
3022    pub collected_count: u32,
3023}
3024
3025#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3026#[serde(rename_all = "snake_case")]
3027pub enum ItemStatusBindingMode {
3028    OnHit,
3029    WhileEquipped,
3030}
3031
3032impl Default for ItemStatusBindingMode {
3033    fn default() -> Self {
3034        Self::OnHit
3035    }
3036}
3037
3038/// Per-instance status effect binding (unique gear grants / enchantments).
3039#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3040pub struct ItemStatusBinding {
3041    pub effect_id: String,
3042    #[serde(default)]
3043    pub mode: ItemStatusBindingMode,
3044    /// Grant template id, `"loot"`, later `"altar"`, etc.
3045    #[serde(default)]
3046    pub source: String,
3047    #[serde(default)]
3048    pub applied_at_tick: u64,
3049    /// `None` = permanent until overwritten / dispelled.
3050    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
3051    #[serde(default)]
3052    pub expires_at_tick: Option<u64>,
3053}
3054
3055#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3056pub struct ItemStack {
3057    pub template_id: String,
3058    pub quantity: u32,
3059    /// Stable instance id — preserved across checkpoint/sync when set.
3060    #[serde(default)]
3061    pub item_instance_id: Option<Uuid>,
3062    /// Per-instance metadata (stat rolls, soul-bind, lock ids, etc.).
3063    #[serde(default)]
3064    pub props: BTreeMap<String, String>,
3065    /// Instance-only status effects (template effects live on the item def).
3066    #[serde(default)]
3067    pub status_bindings: Vec<ItemStatusBinding>,
3068    /// Nested contents when this stack is a container instance.
3069    #[serde(default)]
3070    pub contents: Vec<ItemStack>,
3071    /// From item catalog when sent on wire (display only).
3072    #[serde(default)]
3073    pub display_name: Option<String>,
3074    /// From item catalog when sent on wire (`weapon`, `consumable`, …).
3075    #[serde(default)]
3076    pub category: Option<String>,
3077    /// Per-unit mass in kg from catalog (display / move UX).
3078    #[serde(default)]
3079    pub base_mass: Option<f32>,
3080    /// Per-unit volume from catalog (display / move UX).
3081    #[serde(default)]
3082    pub base_volume: Option<f32>,
3083    /// Container capacity when this stack is a container template.
3084    #[serde(default)]
3085    pub capacity_volume: Option<f32>,
3086    /// Whether the template stacks in inventory (from catalog).
3087    #[serde(default)]
3088    pub stackable: Option<bool>,
3089    /// Can be placed on the ground from inventory (from catalog).
3090    #[serde(default)]
3091    pub world_placeable: Option<bool>,
3092    /// Hired-worker lodging capacity when this template is placed (from catalog).
3093    #[serde(default)]
3094    pub worker_lodging_capacity: Option<u32>,
3095    /// Body slot this template equips into (from catalog; display / Equip UI).
3096    #[serde(default)]
3097    pub equip_slot: Option<BodySlot>,
3098    /// Template baseline armor rating (from catalog).
3099    #[serde(default)]
3100    pub armor_physical: Option<f32>,
3101    /// Template resists damage_type → value (from catalog).
3102    #[serde(default)]
3103    pub resists: Vec<(String, f32)>,
3104    /// Weapon hand occupancy when category is weapon (`1` or `2`).
3105    #[serde(default)]
3106    pub hand_slots: Option<u8>,
3107    /// Market-hall list eligibility from catalog (display / client pickers).
3108    #[serde(default)]
3109    pub listable: Option<bool>,
3110    /// Catalog NPC trade base (`base_value_copper`) for dump-queue payout hints.
3111    #[serde(default)]
3112    pub base_value_copper: Option<u32>,
3113}
3114
3115impl ItemStack {
3116    pub fn simple(template_id: impl Into<String>, quantity: u32) -> Self {
3117        Self {
3118            template_id: template_id.into(),
3119            quantity,
3120            ..Default::default()
3121        }
3122    }
3123}
3124
3125impl Default for ItemStack {
3126    fn default() -> Self {
3127        Self {
3128            template_id: String::new(),
3129            quantity: 0,
3130            item_instance_id: None,
3131            props: BTreeMap::new(),
3132            status_bindings: Vec::new(),
3133            contents: Vec::new(),
3134            display_name: None,
3135            category: None,
3136            base_mass: None,
3137            base_volume: None,
3138            capacity_volume: None,
3139            stackable: None,
3140            world_placeable: None,
3141            worker_lodging_capacity: None,
3142            equip_slot: None,
3143            armor_physical: None,
3144            resists: Vec::new(),
3145            hand_slots: None,
3146            listable: None,
3147            base_value_copper: None,
3148        }
3149    }
3150}
3151
3152/// Carry encumbrance band (`plans/57`). HUD maps Light/Heavy/Orange/Over → green/yellow/orange/red.
3153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3154#[serde(rename_all = "snake_case")]
3155pub enum EncumbranceState {
3156    #[default]
3157    Light,
3158    Heavy,
3159    Orange,
3160    Over,
3161}
3162
3163impl EncumbranceState {
3164    /// Player-facing band name (not a raw id).
3165    pub fn label(self) -> &'static str {
3166        match self {
3167            Self::Light => "Light",
3168            Self::Heavy => "Heavy",
3169            Self::Orange => "Overloaded",
3170            Self::Over => "Over",
3171        }
3172    }
3173
3174    /// Sprint is forbidden in the red (Over) band.
3175    pub fn allows_sprint(self) -> bool {
3176        !matches!(self, Self::Over)
3177    }
3178}
3179
3180/// Body region a wearable item occupies — at most one item equipped per slot.
3181/// Armor, cloak, jewelry, and carriers (`Back` backpack, `Waist` belt). Hands
3182/// (mainhand / offhand) are separate combat sockets — not `BodySlot`.
3183#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)]
3184#[serde(rename_all = "snake_case")]
3185pub enum BodySlot {
3186    Head,
3187    /// Chest / torso armor. Serde alias `body` for older YAML / saves.
3188    #[serde(alias = "body")]
3189    Chest,
3190    /// Gauntlets / bracers / sleeves. Serde alias `arms` for older YAML / saves.
3191    #[serde(alias = "arms")]
3192    Forearms,
3193    Legs,
3194    Feet,
3195    Cloak,
3196    Back,
3197    Waist,
3198    Earrings,
3199    Necklace,
3200    Eyeglasses,
3201    /// Explicit rename: plain `snake_case` would be `ring_left1`.
3202    #[serde(rename = "ring_left_1", alias = "ring_left1")]
3203    RingLeft1,
3204    #[serde(rename = "ring_left_2", alias = "ring_left2")]
3205    RingLeft2,
3206    #[serde(rename = "ring_right_1", alias = "ring_right1")]
3207    RingRight1,
3208    #[serde(rename = "ring_right_2", alias = "ring_right2")]
3209    RingRight2,
3210}
3211
3212impl BodySlot {
3213    /// All worn slots in paperdoll / catalog order.
3214    pub const ALL: [BodySlot; 15] = [
3215        BodySlot::Head,
3216        BodySlot::Chest,
3217        BodySlot::Forearms,
3218        BodySlot::Legs,
3219        BodySlot::Feet,
3220        BodySlot::Cloak,
3221        BodySlot::Back,
3222        BodySlot::Waist,
3223        BodySlot::Earrings,
3224        BodySlot::Necklace,
3225        BodySlot::Eyeglasses,
3226        BodySlot::RingLeft1,
3227        BodySlot::RingLeft2,
3228        BodySlot::RingRight1,
3229        BodySlot::RingRight2,
3230    ];
3231
3232    pub fn as_str(self) -> &'static str {
3233        match self {
3234            BodySlot::Head => "head",
3235            BodySlot::Chest => "chest",
3236            BodySlot::Forearms => "forearms",
3237            BodySlot::Legs => "legs",
3238            BodySlot::Feet => "feet",
3239            BodySlot::Cloak => "cloak",
3240            BodySlot::Back => "back",
3241            BodySlot::Waist => "waist",
3242            BodySlot::Earrings => "earrings",
3243            BodySlot::Necklace => "necklace",
3244            BodySlot::Eyeglasses => "eyeglasses",
3245            BodySlot::RingLeft1 => "ring_left_1",
3246            BodySlot::RingLeft2 => "ring_left_2",
3247            BodySlot::RingRight1 => "ring_right_1",
3248            BodySlot::RingRight2 => "ring_right_2",
3249        }
3250    }
3251}
3252
3253/// Where an item lives for `MoveItem` / open-container UX.
3254#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3255#[serde(rename_all = "snake_case")]
3256pub enum InventoryLocation {
3257    /// Loose on-person inventory (not inside a worn/placed container).
3258    Root,
3259    /// Inside a worn item (backpack contents, or a pouch clipped onto a worn belt).
3260    Worn { slot: BodySlot },
3261    /// Inside a world-placed container.
3262    Placed { container_id: String },
3263    /// Virtual key ring — only `container_key` items; zero carry mass; persists with combat profile.
3264    Keychain,
3265    /// Virtual whisper pouch — only `whisper_stone` items; zero carry mass.
3266    WhisperPouch,
3267}
3268
3269/// AOI view of a placeable chest on the map.
3270#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3271pub struct PlacedContainerView {
3272    pub id: String,
3273    pub template_id: String,
3274    pub display_name: String,
3275    pub x: f32,
3276    pub y: f32,
3277    pub z: f32,
3278    pub locked: bool,
3279    /// Observer can open (unlocked, or holds matching key).
3280    #[serde(default)]
3281    pub accessible: bool,
3282    #[serde(default)]
3283    pub owner_character_id: Option<Uuid>,
3284    /// Nested contents when `accessible` (empty when locked without key).
3285    #[serde(default)]
3286    pub contents: Vec<ItemStack>,
3287    /// Lock id for matching `container_key` props (`opens_lock_id`).
3288    #[serde(default)]
3289    pub lock_id: Option<String>,
3290    /// Internal storage capacity (liters) from item catalog.
3291    #[serde(default)]
3292    pub capacity_volume: Option<f32>,
3293    /// Container instance id (for MoveItem parent targeting).
3294    #[serde(default)]
3295    pub item_instance_id: Option<Uuid>,
3296    /// Optional gfx tile id from the item template.
3297    #[serde(default)]
3298    pub tile_id: Option<String>,
3299    /// Hired-worker slots when this is placed lodging (`category: lodging`).
3300    #[serde(default)]
3301    pub worker_lodging_capacity: Option<u32>,
3302    /// From item template — blocks movement and autopath when placed.
3303    #[serde(default)]
3304    pub blocking: bool,
3305    /// Collision radius (m) for pathfinding when `blocking`.
3306    #[serde(default)]
3307    pub blocking_radius_m: f32,
3308    /// Interior space when placed indoors (`None` = outdoors). Postcard always
3309    /// serializes optionals so decode stays aligned — bump `PROTOCOL_VERSION`.
3310    #[serde(default)]
3311    pub building_id: Option<String>,
3312}
3313
3314#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3315pub struct BlueprintIngredientView {
3316    pub template_id: String,
3317    pub quantity: u32,
3318    /// Always serialize — `true` is not bool::default() so postcard keeps it; explicit for clarity.
3319    pub consumed: bool,
3320    /// Catalog display name for UI (never show bare template_id when this is set).
3321    #[serde(default)]
3322    pub display_name: String,
3323}
3324
3325#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3326pub struct ToolRequirementView {
3327    pub item: String,
3328    /// Always serialize — postcard omits `false` by default, which breaks roundtrip without explicit value.
3329    pub consumed: bool,
3330    /// Catalog display name for UI.
3331    #[serde(default)]
3332    pub display_name: String,
3333}
3334
3335#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3336pub struct SkillRequirementView {
3337    pub skill: String,
3338    pub level: u32,
3339}
3340
3341#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3342pub struct BlueprintView {
3343    pub id: String,
3344    pub label: String,
3345    pub output: String,
3346    pub output_qty: u32,
3347    pub craft_ticks: u32,
3348    pub inputs: Vec<BlueprintIngredientView>,
3349    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
3350    #[serde(default)]
3351    pub station: Option<String>,
3352    #[serde(default)]
3353    pub category: Option<String>,
3354    #[serde(default)]
3355    pub required_tools: Vec<ToolRequirementView>,
3356    #[serde(default)]
3357    pub skill: Option<SkillRequirementView>,
3358    #[serde(default)]
3359    pub failure_chance: f32,
3360    /// Copper cost for the employer to teach this recipe to a hired worker.
3361    #[serde(default)]
3362    pub worker_train_copper: u64,
3363    /// Catalog display name for `output` (UI must prefer this over the raw template id).
3364    #[serde(default)]
3365    pub output_display_name: String,
3366    /// Derived craft dependency depth (1 = only raw inputs). Tools do not deepen this.
3367    #[serde(default)]
3368    pub craft_tier: u32,
3369}
3370
3371/// Per-kind pathfinding params replicated so clients share server content speeds.
3372#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3373pub struct TerrainKindNavView {
3374    pub kind: TerrainKindView,
3375    #[serde(default = "default_move_speed_mult_one")]
3376    pub move_speed_mult: f32,
3377    #[serde(default)]
3378    pub impassable: bool,
3379}
3380
3381fn default_move_speed_mult_one() -> f32 {
3382    1.0
3383}
3384
3385/// Terrain overlay from segment YAML (`terrain_zones`).
3386#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3387#[serde(rename_all = "snake_case")]
3388pub enum TerrainKindView {
3389    #[default]
3390    Grass,
3391    Dirt,
3392    Tilled,
3393    Desert,
3394    Hill,
3395    Bog,
3396    Beach,
3397    ShallowWater,
3398    DeepWater,
3399    Trail,
3400    Road,
3401    Rock,
3402}
3403
3404#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3405pub struct TerrainZoneView {
3406    pub id: String,
3407    pub x0: f32,
3408    pub y0: f32,
3409    pub x1: f32,
3410    pub y1: f32,
3411    #[serde(default)]
3412    pub kind: TerrainKindView,
3413    /// Ground elevation at this zone (m).
3414    #[serde(default)]
3415    pub elevation: f32,
3416    /// Optional map glyph override (single character); falls back to terrain kind catalog.
3417    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
3418    #[serde(default)]
3419    pub glyph: Option<String>,
3420    /// Optional color (`#RRGGBB` or ratatui name); falls back to kind / elevation tint.
3421    #[serde(default)]
3422    pub color: Option<String>,
3423    /// Optional gfx tile id (`terrain.grass`, …) — presentation only.
3424    #[serde(default)]
3425    pub tile_id: Option<String>,
3426    /// Overlap priority — higher wins (`segment.terrain_zones`).
3427    #[serde(default)]
3428    pub z_order: i32,
3429    /// In-progress till/plant channel on this cell (sim ticks).
3430    #[serde(default)]
3431    pub channel_start_tick: Option<Tick>,
3432    #[serde(default)]
3433    pub channel_end_tick: Option<Tick>,
3434}
3435
3436/// Axis-aligned rect used by property / tax zone views.
3437#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3438pub struct ZoneRectView {
3439    pub x0: f32,
3440    pub y0: f32,
3441    pub x1: f32,
3442    pub y1: f32,
3443}
3444
3445/// Crown property zone (claimable land) — plan 40.
3446#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3447pub struct PropertyZoneView {
3448    pub id: String,
3449    /// Designer label when present; clients prefer this over id.
3450    #[serde(default)]
3451    pub label: Option<String>,
3452    pub rects: Vec<ZoneRectView>,
3453    #[serde(default)]
3454    pub z_order: i32,
3455    pub crown_price_copper: u64,
3456    pub upkeep_copper_per_day: u64,
3457    #[serde(default)]
3458    pub max_area_m2: Option<f32>,
3459    #[serde(default)]
3460    pub owner_tax_discount_bps: u32,
3461}
3462
3463/// Tax overlay for claim premium preview.
3464#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3465pub struct TaxZoneView {
3466    pub id: String,
3467    #[serde(default)]
3468    pub label: Option<String>,
3469    pub rects: Vec<ZoneRectView>,
3470    #[serde(default)]
3471    pub z_order: i32,
3472    pub rate_bps: u32,
3473    #[serde(default)]
3474    pub flat_copper: u64,
3475    /// Market-hall sales tax in basis points of sale total.
3476    #[serde(default)]
3477    pub market_sales_tax_bps: u32,
3478    /// Optional flat copper per market-hall purchase.
3479    #[serde(default)]
3480    pub market_sales_flat_copper: u32,
3481}
3482
3483/// Town / security / PvP boundary overlay (plan 43).
3484#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3485pub struct BoundaryZoneView {
3486    pub id: String,
3487    #[serde(default)]
3488    pub label: Option<String>,
3489    pub rects: Vec<ZoneRectView>,
3490    #[serde(default)]
3491    pub z_order: i32,
3492    #[serde(default, skip_serializing_if = "Option::is_none")]
3493    pub jurisdiction_id: Option<String>,
3494    #[serde(default = "default_true")]
3495    pub worker_logistics: bool,
3496    #[serde(default)]
3497    pub security_tier: String,
3498    #[serde(default)]
3499    pub pvp_mode: String,
3500    #[serde(default = "default_true")]
3501    pub crime_enabled: bool,
3502    #[serde(default)]
3503    pub guard_response: bool,
3504    /// When true, placeable props do not block movement inside this zone.
3505    #[serde(default)]
3506    pub pass_through_props: bool,
3507    /// `fov_los` (default) or `full_aoi`.
3508    #[serde(default)]
3509    pub presence_mode: String,
3510}
3511
3512/// Random encounter overlay (plan 54).
3513#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3514pub struct EncounterZoneView {
3515    pub id: String,
3516    #[serde(default)]
3517    pub label: Option<String>,
3518    pub rects: Vec<ZoneRectView>,
3519    #[serde(default)]
3520    pub z_order: i32,
3521}
3522
3523fn default_true() -> bool {
3524    true
3525}
3526
3527/// Growth / fertility overlay — faster respawn & farm tuning (plan 37 / 40).
3528#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3529pub struct GrowthZoneView {
3530    pub id: String,
3531    #[serde(default)]
3532    pub label: Option<String>,
3533    pub rects: Vec<ZoneRectView>,
3534    #[serde(default)]
3535    pub z_order: i32,
3536    #[serde(default = "default_one_f32")]
3537    pub fertility: f32,
3538}
3539
3540fn default_one_f32() -> f32 {
3541    1.0
3542}
3543
3544/// Climate / biome overlay (plan 38).
3545#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3546pub struct BiomeZoneView {
3547    pub id: String,
3548    #[serde(default)]
3549    pub label: Option<String>,
3550    pub rects: Vec<ZoneRectView>,
3551    #[serde(default)]
3552    pub z_order: i32,
3553    pub biome_id: String,
3554}
3555
3556/// Named tenant on a property plot (farm access + tax discount).
3557#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3558pub struct FarmGrantView {
3559    pub character_id: Uuid,
3560    /// Display name when known (AOI / online); empty if offline-only id.
3561    #[serde(default)]
3562    pub character_label: String,
3563    pub tax_discount_bps: u32,
3564}
3565
3566/// Claimed plot visible to clients (plan 40).
3567#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3568pub struct PropertyPlotView {
3569    pub plot_id: Uuid,
3570    pub property_zone_id: String,
3571    #[serde(default)]
3572    pub zone_label: Option<String>,
3573    pub deed_instance_id: Uuid,
3574    pub x0: f32,
3575    pub y0: f32,
3576    pub x1: f32,
3577    pub y1: f32,
3578    pub upkeep_copper_per_day: u64,
3579    pub arrears_days: u32,
3580    /// Observer currently holds this plot's deed.
3581    #[serde(default)]
3582    pub is_mine: bool,
3583    /// Observer may cultivate/plant/harvest on this plot (deed, owner, public, or grant).
3584    #[serde(default)]
3585    pub may_farm: bool,
3586    /// Book value when known (purchase / last private sale).
3587    #[serde(default)]
3588    pub purchase_basis_copper: u64,
3589    #[serde(default)]
3590    pub farm_public: bool,
3591    #[serde(default)]
3592    pub public_tax_discount_bps: u32,
3593    #[serde(default)]
3594    pub farm_allow: Vec<FarmGrantView>,
3595    /// Owner character when known.
3596    #[serde(default)]
3597    pub owner_character_id: Option<Uuid>,
3598    #[serde(default)]
3599    pub owner_label: Option<String>,
3600    /// Player building on this plot, if any.
3601    #[serde(default)]
3602    pub building_id: Option<String>,
3603    /// Immutable short code derived from `plot_id` (e.g. `xyz1234a`).
3604    #[serde(default)]
3605    pub plot_code: String,
3606    /// Owner-chosen label (defaults to `plot_code`).
3607    #[serde(default)]
3608    pub label: String,
3609}
3610
3611/// Subset of server settings for client-side claim quotes.
3612#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3613pub struct PropertyPlotSettingsView {
3614    pub min_plot_area_m2: f32,
3615    pub tax_premium_weight: f32,
3616    pub sellback_bps: u32,
3617}
3618
3619/// Walkable platform at a fixed z (`segment.z_platforms`).
3620#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3621pub struct ZPlatformView {
3622    pub id: String,
3623    pub z: f32,
3624    pub x0: f32,
3625    pub y0: f32,
3626    pub x1: f32,
3627    pub y1: f32,
3628}
3629
3630/// Stairs / ramp linking two z bands (`segment.z_transitions`).
3631#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3632pub struct ZTransitionView {
3633    pub id: String,
3634    pub z_from: f32,
3635    pub z_to: f32,
3636    pub x0: f32,
3637    pub y0: f32,
3638    pub x1: f32,
3639    pub y1: f32,
3640}
3641
3642#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3643pub struct BuildingView {
3644    pub id: String,
3645    pub label: String,
3646    pub x: f32,
3647    pub y: f32,
3648    pub width_m: f32,
3649    pub depth_m: f32,
3650    #[serde(default)]
3651    pub interior_blueprint: Option<String>,
3652    #[serde(default)]
3653    pub tags: Vec<String>,
3654    /// Boundary zones this market hall participates in (`plans/10`).
3655    #[serde(default)]
3656    pub market_boundary_zone_ids: Vec<String>,
3657    /// Escrow capacity when tagged `market`. None → server default.
3658    #[serde(default)]
3659    pub market_max_volume: Option<f32>,
3660    /// Wall art set id (`schemas/gfx-sprites.schema.json` `wall_set`). `None` → `classic_stone`
3661    /// (legacy `building.wall_h`/`wall_v`/`wall_corner`/`door_open`/`door_closed` sprites).
3662    #[serde(default)]
3663    pub wall_set: Option<String>,
3664    /// Roof art set id (forge `roof_set` output_stem). `None` → `classic_stone`.
3665    #[serde(default)]
3666    pub roof_set: Option<String>,
3667}
3668
3669/// Default wall/roof set id when a building doesn't specify one — keeps existing
3670/// content rendering pixel-identical (`plans/47` Workstream C).
3671pub const DEFAULT_BUILDING_ART_SET: &str = "classic_stone";
3672
3673impl BuildingView {
3674    pub fn effective_wall_set(&self) -> &str {
3675        self.wall_set
3676            .as_deref()
3677            .filter(|s| !s.is_empty())
3678            .unwrap_or(DEFAULT_BUILDING_ART_SET)
3679    }
3680
3681    pub fn effective_roof_set(&self) -> &str {
3682        self.roof_set
3683            .as_deref()
3684            .filter(|s| !s.is_empty())
3685            .unwrap_or(DEFAULT_BUILDING_ART_SET)
3686    }
3687}
3688
3689#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3690pub struct DoorView {
3691    pub id: String,
3692    pub building_id: String,
3693    pub x: f32,
3694    pub y: f32,
3695    #[serde(default)]
3696    pub open: bool,
3697    #[serde(default)]
3698    pub portal: Option<String>,
3699    /// Exterior door locked: player-house key lock, or map-building door hours closed.
3700    /// Clients draw the padlock badge when `locked && !open`.
3701    #[serde(default)]
3702    pub locked: bool,
3703    /// Observer can open/close/enter. False when key-locked or outside door hours.
3704    #[serde(default = "default_door_accessible")]
3705    pub accessible: bool,
3706    #[serde(default)]
3707    pub lock_id: Option<Uuid>,
3708}
3709
3710fn default_door_accessible() -> bool {
3711    true
3712}
3713
3714/// Client → server interior room layout (confirm edit).
3715#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3716pub struct InteriorRoomEdit {
3717    pub id: String,
3718    pub label: String,
3719    pub x0: f32,
3720    pub y0: f32,
3721    pub x1: f32,
3722    pub y1: f32,
3723}
3724
3725#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3726pub struct InteriorRoomDoorEdit {
3727    pub id: String,
3728    pub room_a: String,
3729    pub room_b: String,
3730    pub x: f32,
3731    pub y: f32,
3732}
3733
3734#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3735pub struct InteriorRoomView {
3736    pub id: String,
3737    pub label: String,
3738    pub floor: i32,
3739    pub x0: f32,
3740    pub y0: f32,
3741    pub x1: f32,
3742    pub y1: f32,
3743    #[serde(default)]
3744    pub floor_color: Option<String>,
3745    #[serde(default)]
3746    pub floor_glyph: Option<String>,
3747}
3748
3749#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3750pub struct InteriorDoorView {
3751    pub id: String,
3752    pub room_a: String,
3753    pub room_b: String,
3754    pub x: f32,
3755    pub y: f32,
3756    pub kind: String,
3757    #[serde(default)]
3758    pub x_a: Option<f32>,
3759    #[serde(default)]
3760    pub y_a: Option<f32>,
3761    #[serde(default)]
3762    pub x_b: Option<f32>,
3763    #[serde(default)]
3764    pub y_b: Option<f32>,
3765}
3766
3767#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3768pub struct InteriorMapView {
3769    pub building_id: String,
3770    pub blueprint_id: String,
3771    pub background_color: String,
3772    #[serde(default)]
3773    pub default_floor_color: Option<String>,
3774    #[serde(default = "default_floor_height_view")]
3775    pub floor_height_m: f32,
3776    /// Walkable platforms per floor (interior z-bands).
3777    #[serde(default)]
3778    pub z_platforms: Vec<ZPlatformView>,
3779    #[serde(default)]
3780    pub z_transitions: Vec<ZTransitionView>,
3781    pub rooms: Vec<InteriorRoomView>,
3782    #[serde(default)]
3783    pub room_doors: Vec<InteriorDoorView>,
3784}
3785
3786fn default_floor_height_view() -> f32 {
3787    3.0
3788}
3789
3790#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3791pub struct NpcView {
3792    pub id: String,
3793    pub label: String,
3794    pub role: String,
3795    pub x: f32,
3796    pub y: f32,
3797    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
3798    #[serde(default)]
3799    pub building_id: Option<String>,
3800    /// Authoritative sim entity for wildlife / combat targets.
3801    #[serde(default)]
3802    pub entity_id: Option<EntityId>,
3803    #[serde(default)]
3804    pub life_state: Option<LifeState>,
3805    #[serde(default)]
3806    pub hp_pct: Option<f32>,
3807    /// True when this NPC has buy/sell/teach offers (`npc_has_market`).
3808    #[serde(default)]
3809    pub can_trade: bool,
3810    /// Exact item template IDs this NPC buys, for worker-route sell pickers.
3811    #[serde(default)]
3812    pub buy_templates: Vec<String>,
3813    /// Gfx sprite sheet id (`assets/gfx/sprites/`). Client falls back to `npc.{id}` / `npc.{role}`.
3814    #[serde(default)]
3815    pub tile_id: Option<String>,
3816    /// Wildlife FSM state (`idle`, `chase`, `combat`, …) when behavior-driven (debug).
3817    #[serde(default)]
3818    pub behavior_state: Option<String>,
3819    /// Canonical gfx presentation key (`combat`, `pursue`, `walking`, `talking`, …).
3820    #[serde(default)]
3821    pub presentation_state: Option<String>,
3822    /// Resolved gfx sprite mode for `tile_id` (server-computed).
3823    #[serde(default)]
3824    pub sprite_mode: Option<String>,
3825    /// Paperdoll skin id (`assets/paperdoll/skins/`). Client prefers this over `tile_id` when baked.
3826    #[serde(default)]
3827    pub paperdoll_ref: Option<String>,
3828    /// World draw size in map cells (from paperdoll skin `draw_scale`; 1.0 = one cell).
3829    #[serde(default = "default_draw_scale")]
3830    pub draw_scale: f32,
3831    /// Authoritative body yaw (radians) for wildlife LoS debug (F7).
3832    #[serde(default)]
3833    pub yaw: Option<f32>,
3834    /// Active visual acquire cone (degrees); 360 when engaged.
3835    #[serde(default)]
3836    pub perception_fov_deg: Option<f32>,
3837    /// Sight radius used for acquire/tracking (meters).
3838    #[serde(default)]
3839    pub perception_sight_m: Option<f32>,
3840    /// Hearing radius from behavior (meters); omitted when deaf.
3841    #[serde(default)]
3842    pub perception_hear_m: Option<f32>,
3843    /// Per-observer quest verbs for this NPC (offer / talk / give). Empty for wildlife.
3844    #[serde(default)]
3845    pub quest_verbs: Vec<NpcQuestVerb>,
3846}
3847
3848/// Quest-tied NPC menu verb. `kind` is `offer`, `talk`, or `give`.
3849#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
3850pub struct NpcQuestVerb {
3851    pub quest_id: String,
3852    /// Player-facing verb (quest title or authored `talk_verb`).
3853    pub label: String,
3854    pub kind: String,
3855}
3856
3857impl NpcQuestVerb {
3858    pub const KIND_OFFER: &'static str = "offer";
3859    pub const KIND_TALK: &'static str = "talk";
3860    pub const KIND_GIVE: &'static str = "give";
3861}
3862
3863#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3864pub struct UseResult {
3865    pub template_id: String,
3866    pub hunger_restored: f32,
3867    pub thirst_restored: f32,
3868    #[serde(default)]
3869    pub health_restored: f32,
3870    #[serde(default)]
3871    pub mana_restored: f32,
3872    #[serde(default)]
3873    pub cleared_dot_ids: Vec<String>,
3874}
3875
3876#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3877pub struct CraftResult {
3878    pub blueprint_id: String,
3879    pub outputs: Vec<ItemStack>,
3880    pub consumed: Vec<ItemStack>,
3881    /// 1-based index within the submitted batch (1 when not batching).
3882    #[serde(default = "default_one")]
3883    pub batch_index: u32,
3884    /// Total crafts requested in this batch (1 when not batching).
3885    #[serde(default = "default_one")]
3886    pub batch_total: u32,
3887}
3888
3889fn default_one() -> u32 {
3890    1
3891}
3892
3893#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3894pub struct DeathNotice {
3895    pub entity_id: EntityId,
3896    pub respawn_x: f32,
3897    pub respawn_y: f32,
3898    pub message: String,
3899}
3900
3901#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3902pub struct InteractionNotice {
3903    pub target_id: String,
3904    pub message: String,
3905    #[serde(default)]
3906    pub coins_delta: i32,
3907    #[serde(default)]
3908    pub inventory_delta: Vec<ItemStack>,
3909}
3910
3911#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3912#[serde(rename_all = "snake_case")]
3913pub enum NpcTalkTrustFlag {
3914    Stranger,
3915    Acquainted,
3916    Trusted,
3917}
3918
3919#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
3920#[serde(rename_all = "snake_case")]
3921pub enum NpcTalkDepth {
3922    #[default]
3923    Full,
3924    Brief,
3925    Unavailable,
3926}
3927
3928#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3929pub struct NpcTalkOpened {
3930    pub npc_id: String,
3931    pub npc_label: String,
3932    pub greeting: String,
3933    pub trust_flag: NpcTalkTrustFlag,
3934    #[serde(default)]
3935    pub talk_depth: NpcTalkDepth,
3936    #[serde(default = "default_true")]
3937    pub trade_allowed: bool,
3938    /// Keyword/topic hints for deterministic chat (e.g. "Trade", "Goblins").
3939    #[serde(default)]
3940    pub suggested_topics: Vec<String>,
3941}
3942
3943#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3944pub struct NpcTalkPending {
3945    pub npc_id: String,
3946}
3947
3948#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3949pub struct NpcTalkReply {
3950    pub npc_id: String,
3951    pub line: String,
3952    pub trust_flag: NpcTalkTrustFlag,
3953    #[serde(default)]
3954    pub wind_down: bool,
3955    #[serde(default)]
3956    pub trade_disabled: bool,
3957}
3958
3959#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3960pub struct NpcTalkClosed {
3961    pub npc_id: String,
3962}
3963
3964#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3965pub struct NpcTalkError {
3966    pub npc_id: String,
3967    pub reason: String,
3968}
3969
3970#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3971#[serde(rename_all = "snake_case")]
3972pub enum QuestStatusView {
3973    Available,
3974    Active,
3975    Completed,
3976}
3977
3978#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3979pub struct QuestObjectiveProgress {
3980    pub label: String,
3981    pub current: u32,
3982    pub required: u32,
3983    pub done: bool,
3984    /// `talk_npc` / `kill_npc` / `harvest_item` / `give_item` / `interact` /
3985    /// `craft` / `buy_item` / `sell_item` / `enter_building` / `use_item` /
3986    /// `pickup_item` / `learn_blueprint`.
3987    #[serde(default)]
3988    pub kind: String,
3989    #[serde(default)]
3990    pub npc_ref: Option<String>,
3991    #[serde(default)]
3992    pub item_template: Option<String>,
3993    #[serde(default)]
3994    pub blueprint_id: Option<String>,
3995    #[serde(default)]
3996    pub building_id: Option<String>,
3997}
3998
3999/// One item granted by a quest step or final completion reward.
4000#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
4001pub struct QuestRewardItemView {
4002    pub template_id: String,
4003    /// Friendly catalog name for HUD / TUI (never show raw template id alone).
4004    #[serde(default)]
4005    pub display_name: String,
4006    pub quantity: u32,
4007}
4008
4009/// Coins + items granted when a step (or the quest) completes.
4010#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
4011pub struct QuestRewardView {
4012    #[serde(default)]
4013    pub coins: u32,
4014    #[serde(default)]
4015    pub items: Vec<QuestRewardItemView>,
4016}
4017
4018impl QuestRewardView {
4019    pub fn is_empty(&self) -> bool {
4020        self.coins == 0 && self.items.is_empty()
4021    }
4022}
4023
4024#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4025#[serde(rename_all = "snake_case")]
4026pub enum QuestStepStatusView {
4027    #[default]
4028    Pending,
4029    Current,
4030    Done,
4031}
4032
4033/// Authored step summary for quest log / journal UI.
4034#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4035pub struct QuestStepView {
4036    pub id: String,
4037    pub title: String,
4038    #[serde(default)]
4039    pub status: QuestStepStatusView,
4040    #[serde(default)]
4041    pub reward: QuestRewardView,
4042}
4043
4044#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4045pub struct QuestLogEntry {
4046    pub quest_id: String,
4047    pub title: String,
4048    pub description: String,
4049    pub status: QuestStatusView,
4050    #[serde(default)]
4051    pub current_step_id: Option<String>,
4052    #[serde(default)]
4053    pub current_step_title: String,
4054    #[serde(default)]
4055    pub current_step_index: u32,
4056    #[serde(default)]
4057    pub objectives: Vec<QuestObjectiveProgress>,
4058    /// Reward granted when the current step completes (active quests).
4059    #[serde(default)]
4060    pub current_step_reward: QuestRewardView,
4061    /// Reward on the final step (quest completion prize).
4062    #[serde(default)]
4063    pub completion_reward: QuestRewardView,
4064    /// Full step list with per-step rewards for the journal.
4065    #[serde(default)]
4066    pub steps: Vec<QuestStepView>,
4067    #[serde(default)]
4068    pub is_tracked: bool,
4069    #[serde(default)]
4070    pub can_withdraw: bool,
4071}
4072
4073#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4074pub struct InteractableView {
4075    pub id: String,
4076    pub kind: String,
4077    pub label: String,
4078    pub x: f32,
4079    pub y: f32,
4080    pub z: f32,
4081    #[serde(default)]
4082    pub board_id: Option<String>,
4083}
4084
4085#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4086pub struct QuestOffer {
4087    pub quest_id: String,
4088    pub title: String,
4089    pub description: String,
4090    #[serde(default)]
4091    pub step_count: u32,
4092}
4093
4094#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4095pub struct QuestCatalogEntry {
4096    pub quest_id: String,
4097    pub title: String,
4098    pub description: String,
4099    pub step_count: u32,
4100    #[serde(default)]
4101    pub board_ids: Vec<String>,
4102}
4103
4104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4105pub struct QuestCatalogUpdated {
4106    pub revision: u64,
4107    pub game_day: String,
4108    #[serde(default)]
4109    pub accepted: Vec<QuestCatalogEntry>,
4110    #[serde(default)]
4111    pub retired: Vec<String>,
4112}
4113
4114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4115pub struct QuestNotice {
4116    pub quest_id: String,
4117    pub title: String,
4118    pub message: String,
4119}
4120
4121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4122#[serde(rename_all = "snake_case")]
4123pub enum ShopOfferKind {
4124    Item,
4125    Blueprint,
4126}
4127
4128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4129pub struct ShopOffer {
4130    pub offer_id: String,
4131    pub kind: ShopOfferKind,
4132    pub label: String,
4133    #[serde(default)]
4134    pub template_id: Option<String>,
4135    #[serde(default)]
4136    pub blueprint_id: Option<String>,
4137    pub price_copper: u32,
4138    #[serde(default)]
4139    pub affordable: bool,
4140    #[serde(default)]
4141    pub already_owned: bool,
4142}
4143
4144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4145pub struct ShopBuyLine {
4146    pub template_id: String,
4147    pub label: String,
4148    pub quantity: u32,
4149    pub price_copper: u32,
4150}
4151
4152/// Bank teller interaction panel (`plans/08` §8).
4153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4154pub struct BankPanel {
4155    pub npc_id: String,
4156    pub npc_label: String,
4157    pub bank_balance_copper: u64,
4158    pub on_person_copper: u64,
4159    /// Copper still clearing to other accounts (debited, not yet credited).
4160    #[serde(default)]
4161    pub pending_outgoing_copper: u64,
4162    #[serde(default)]
4163    pub transfer_fee_bps: u32,
4164    #[serde(default)]
4165    pub transfer_clear_ticks: u64,
4166}
4167
4168/// Town storage manager panel (`plans/08` §7).
4169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4170pub struct StoragePanel {
4171    pub npc_id: String,
4172    pub npc_label: String,
4173    pub building_id: String,
4174    pub building_label: String,
4175    pub used_volume: f32,
4176    pub max_volume: f32,
4177    #[serde(default)]
4178    pub contents: Vec<ItemStack>,
4179    /// Other storage buildings that can receive a ship (id, label, distance_m, fee_copper, travel_ticks).
4180    #[serde(default)]
4181    pub ship_destinations: Vec<StorageShipDest>,
4182}
4183
4184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4185pub struct StorageShipDest {
4186    pub building_id: String,
4187    pub label: String,
4188    pub distance_m: f32,
4189    pub fee_copper: u64,
4190    pub travel_ticks: u64,
4191}
4192
4193/// Source or destination for market-hall goods movement
4194/// (`plans/10-economy-and-markets.md` §4.3/§4.4).
4195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4196pub enum GoodsLocation {
4197    /// On-person inventory (root, non-nested).
4198    Person,
4199    /// A town storage vault at a building whose jurisdiction intersects the
4200    /// listing hall's `market_boundary_zone_ids`.
4201    TownStorage { building_id: String },
4202}
4203
4204/// One escrowed market-hall listing, as seen from a browsing hall
4205/// (`plans/10-economy-and-markets.md` §4.2).
4206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4207pub struct MarketListingView {
4208    pub listing_id: Uuid,
4209    pub seller_character_id: Uuid,
4210    /// Seller display name (`prefer-labels-over-ids`).
4211    pub seller_label: String,
4212    pub hall_building_id: String,
4213    pub hall_label: String,
4214    pub template_id: String,
4215    pub display_name: String,
4216    /// Item catalog category (`resource`, `weapon`, …) for client filters.
4217    #[serde(default)]
4218    pub category: String,
4219    pub quantity: u32,
4220    pub unit_price_copper: u64,
4221    /// Total for the full remaining quantity (`quantity * unit_price_copper`).
4222    pub line_total_copper: u64,
4223    /// Dump-queue listing — players cannot buy (`plans/53`).
4224    #[serde(default)]
4225    pub npc_price: bool,
4226    /// Estimated net copper per unit for NPC-price rows (default buyer rates).
4227    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
4228    #[serde(default)]
4229    pub npc_dump_unit_copper: Option<u32>,
4230    /// True when the viewer is the seller — reprice/delist allowed.
4231    pub mine: bool,
4232}
4233
4234/// Town storage vault eligible as a list/buy/delist goods source for a market hall.
4235#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4236pub struct MarketListVault {
4237    pub building_id: String,
4238    /// Designer label (`prefer-labels-over-ids`).
4239    pub building_label: String,
4240    #[serde(default)]
4241    pub contents: Vec<ItemStack>,
4242}
4243
4244/// Market hall clerk panel — browse (zone-linked halls), list / reprice / delist,
4245/// buy confirm (`plans/10-economy-and-markets.md` §4, §10).
4246#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4247pub struct MarketPanel {
4248    pub npc_id: String,
4249    pub npc_label: String,
4250    pub building_id: String,
4251    pub building_label: String,
4252    /// Escrow volume used at *this* hall only (cap is per-hall, `market_max_volume`).
4253    pub used_volume: f32,
4254    pub max_volume: f32,
4255    /// Listings at this hall plus any zone-linked halls (`market_boundary_zone_ids`
4256    /// intersection) — the shared browse book (§4.2).
4257    #[serde(default)]
4258    pub listings: Vec<MarketListingView>,
4259    /// Crown sales tax at the tax zone covering this hall (§6).
4260    #[serde(default)]
4261    pub tax_bps: u32,
4262    #[serde(default)]
4263    pub tax_flat_copper: u32,
4264    /// Zone-eligible town storage vaults the caller can list from (§4.3).
4265    #[serde(default)]
4266    pub list_vaults: Vec<MarketListVault>,
4267}
4268
4269#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4270pub struct ShopCatalog {
4271    pub npc_id: String,
4272    pub npc_label: String,
4273    #[serde(default)]
4274    pub sells: Vec<ShopOffer>,
4275    #[serde(default)]
4276    pub buys: Vec<ShopBuyLine>,
4277}
4278
4279#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4280pub struct HarvestResult {
4281    pub node_id: String,
4282    /// Stack quantity granted (not one-node-one-instance).
4283    pub quantity: u32,
4284    pub item_template: String,
4285    /// Optional DB row id when persisted to control plane.
4286    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
4287    #[serde(default)]
4288    pub item_instance_id: Option<Uuid>,
4289}
4290
4291/// Every on-wire payload is wrapped for versioning and codec uniformity.
4292#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4293pub struct Envelope<T> {
4294    pub protocol_version: u16,
4295    pub payload: T,
4296}
4297
4298impl<T> Envelope<T> {
4299    pub fn new(payload: T) -> Self {
4300        Self {
4301            protocol_version: crate::PROTOCOL_VERSION,
4302            payload,
4303        }
4304    }
4305}
4306
4307/// Session handshake after transport connect.
4308#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4309pub struct Hello {
4310    pub client_name: String,
4311    pub protocol_version: u16,
4312    #[serde(default)]
4313    pub auth: AuthCredential,
4314    /// Required for session auth; embedded in `ApiToken` variant otherwise.
4315    #[serde(default)]
4316    pub character_id: Option<Uuid>,
4317}
4318
4319/// How the client authenticates to the game gateway.
4320/// Uses default serde enum encoding (postcard-compatible; not internally tagged).
4321#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4322#[serde(rename_all = "snake_case")]
4323pub enum AuthCredential {
4324    DevLocal,
4325    Session { token: String },
4326    ApiToken { token: String, character_id: Uuid },
4327}
4328
4329impl Default for AuthCredential {
4330    fn default() -> Self {
4331        Self::DevLocal
4332    }
4333}
4334
4335#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4336pub struct Welcome {
4337    pub session_id: SessionId,
4338    pub entity_id: EntityId,
4339    pub snapshot: Snapshot,
4340}
4341
4342#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4343pub enum ServerMessage {
4344    Welcome(Welcome),
4345    /// Static world layers refreshed (blueprints, catalog, map, terrain) — no client restart.
4346    ContentUpdated(Snapshot),
4347    Tick(TickDelta),
4348    IntentAck {
4349        entity_id: EntityId,
4350        seq: Seq,
4351        tick: Tick,
4352    },
4353    Chat(ChatMessage),
4354    HarvestResult(HarvestResult),
4355    UseResult(UseResult),
4356    CraftResult(CraftResult),
4357    Death(DeathNotice),
4358    Interaction(InteractionNotice),
4359    ShopOpened(ShopCatalog),
4360    NpcTalkOpened(NpcTalkOpened),
4361    NpcTalkPending(NpcTalkPending),
4362    NpcTalkReply(NpcTalkReply),
4363    NpcTalkClosed(NpcTalkClosed),
4364    NpcTalkError(NpcTalkError),
4365    QuestOffer(QuestOffer),
4366    QuestAccepted(QuestNotice),
4367    QuestWithdrawn(QuestNotice),
4368    QuestStepCompleted(QuestNotice),
4369    QuestCompleted(QuestNotice),
4370    QuestCatalogUpdated(QuestCatalogUpdated),
4371    /// Bank teller panel opened (`plans/08` §8).
4372    BankOpened(BankPanel),
4373    /// Town storage manager panel opened (`plans/08` §7).
4374    StorageOpened(StoragePanel),
4375    /// Market hall clerk panel opened or refreshed (`plans/10-economy-and-markets.md`).
4376    MarketOpened(MarketPanel),
4377    /// Player-to-player trade window opened or refreshed.
4378    TradeOpened(TradePanel),
4379    /// Trade ended (cancel, complete, or peer left range).
4380    TradeClosed {
4381        reason: String,
4382    },
4383    /// Hello accepted but play refused (character already online, etc.).
4384    /// Sent instead of Welcome; TCP closes afterward.
4385    ConnectRejected {
4386        reason: String,
4387    },
4388}
4389
4390/// Live player-to-player trade escrow view (both sides see the same presented buckets).
4391#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4392pub struct TradePanel {
4393    pub peer_entity_id: EntityId,
4394    pub peer_name: String,
4395    pub my_presented: Vec<ItemStack>,
4396    pub their_presented: Vec<ItemStack>,
4397    pub i_ready: bool,
4398    pub they_ready: bool,
4399    /// Predicted carry mass after accepting their presented items (and losing mine).
4400    pub my_mass_after: f32,
4401    pub my_mass_max: f32,
4402    pub my_encumbrance_after: EncumbranceState,
4403    /// True when completing would put the local player Over.
4404    pub overburden_warning: bool,
4405}
4406
4407#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4408pub enum ClientMessage {
4409    Hello(Hello),
4410    Intent(Intent),
4411    Disconnect,
4412}
4413
4414#[cfg(test)]
4415mod tests {
4416    use super::*;
4417
4418    #[test]
4419    fn pristine_vitals_state_yields_full_pools() {
4420        let attrs = PrimaryAttributes::default();
4421        let vitals = StoredVitalsState::default().apply_to(attrs);
4422        assert!(vitals.health > 0.0);
4423        assert_eq!(vitals.health, vitals.health_max);
4424        assert!((vitals.mana_max - 61.0).abs() < 0.01);
4425    }
4426
4427    #[test]
4428    fn humanize_snake_id_title_cases_parts() {
4429        assert_eq!(humanize_snake_id("heal_touch"), "Heal Touch");
4430        assert_eq!(humanize_snake_id("fireball"), "Fireball");
4431        assert_eq!(humanize_snake_id("cone_frost"), "Cone Frost");
4432    }
4433
4434    #[test]
4435    fn world_clock_phase_names_normalize_aliases_and_drop_unknown() {
4436        assert_eq!(TimeOfDayPhase::try_from_name("noon"), Some(TimeOfDayPhase::Midday));
4437        assert_eq!(TimeOfDayPhase::from_name("nope"), TimeOfDayPhase::Night);
4438        assert_eq!(
4439            normalize_world_clock_phase_names(["Morning", "dusk", "bogus", "morning"]),
4440            vec!["morning".to_string(), "evening".to_string()]
4441        );
4442    }
4443
4444    #[test]
4445    fn saved_vitals_scale_when_pool_max_increases() {
4446        let mut attrs = PrimaryAttributes::default();
4447        attrs.intelligence = 140;
4448        attrs.wisdom = 140;
4449        let saved = StoredVitalsState {
4450            health: 100.0,
4451            mana: 14.0,
4452            stamina: 100.0,
4453            ..StoredVitalsState::default()
4454        };
4455        let vitals = saved.apply_to(attrs);
4456        assert!(vitals.mana_max > 55.0);
4457        assert!(
4458            (vitals.mana - vitals.mana_max).abs() < 0.01,
4459            "full legacy mana bar migrates to full new bar"
4460        );
4461    }
4462
4463    #[test]
4464    fn empty_vitals_state_is_pristine() {
4465        let pristine = StoredVitalsState {
4466            health: 0.0,
4467            mana: 0.0,
4468            stamina: 0.0,
4469            hunger: 0.0,
4470            thirst: 0.0,
4471            coins: 0,
4472            deaths: 0,
4473            life_state: LifeState::Alive,
4474            winded: false,
4475        };
4476        assert!(pristine.is_pristine());
4477        let vitals = pristine.apply_to(PrimaryAttributes::default());
4478        assert!(vitals.health > 0.0);
4479    }
4480
4481    #[test]
4482    fn stored_vitals_roundtrip_preserves_partial_pools() {
4483        let attrs = PrimaryAttributes::default();
4484        let mut live = PlayerVitals::from_attributes(attrs);
4485        live.health = 25.0;
4486        live.hunger = 77.0;
4487        live.deaths = 2;
4488        live.winded = true;
4489        let stored = StoredVitalsState::from_live(&live);
4490        let restored = stored.apply_to(attrs);
4491        assert!(
4492            (restored.health - 25.0).abs() < 0.01,
4493            "partial HP below cap stays absolute"
4494        );
4495        assert_eq!(restored.hunger, 77.0);
4496        assert_eq!(restored.deaths, 2);
4497        assert!(restored.winded);
4498    }
4499
4500    #[test]
4501    fn skill_tiers_start_at_zero() {
4502        let skill = SkillProgress::default();
4503        assert_eq!(skill.level, 0);
4504        assert_eq!(skill.display_tier(), 0);
4505        let trained = SkillProgress {
4506            level: 250,
4507            last_trained_tick: 1,
4508        };
4509        assert_eq!(trained.display_tier(), 2);
4510    }
4511
4512    #[test]
4513    fn quest_server_messages_roundtrip_json() {
4514        use crate::codec::{Codec, PostcardCodec};
4515
4516        let offer = ServerMessage::QuestOffer(QuestOffer {
4517            quest_id: "ada_goblin_hunt".into(),
4518            title: "Goblin Trouble".into(),
4519            description: "Help Ada".into(),
4520            step_count: 3,
4521        });
4522        let notice = ServerMessage::QuestAccepted(QuestNotice {
4523            quest_id: "ada_goblin_hunt".into(),
4524            title: "Goblin Trouble".into(),
4525            message: "Quest accepted".into(),
4526        });
4527        for msg in [offer, notice] {
4528            let bytes = PostcardCodec.encode(&msg).unwrap();
4529            let decoded: ServerMessage = PostcardCodec.decode(&bytes).unwrap();
4530            assert_eq!(decoded, msg);
4531        }
4532    }
4533
4534    #[test]
4535    fn hotbar_consumable_binding_roundtrips() {
4536        let binding = hotbar_consumable_binding("vegetable_soup");
4537        assert_eq!(binding, "item:vegetable_soup");
4538        assert!(hotbar_binding_is_consumable(&binding));
4539        assert_eq!(hotbar_consumable_template(&binding), Some("vegetable_soup"));
4540        assert!(!hotbar_binding_is_consumable("fireball"));
4541        assert_eq!(hotbar_consumable_template("fireball"), None);
4542    }
4543}