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