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 and proximity overlay.
2115#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
2116pub struct WorkerVitalsSummary {
2117    pub health_pct: f32,
2118    pub stamina_pct: f32,
2119    #[serde(default)]
2120    pub mana_pct: f32,
2121    #[serde(default)]
2122    pub hunger_pct: f32,
2123    #[serde(default)]
2124    pub thirst_pct: f32,
2125}
2126
2127/// High-level route shape — `harvest_loop` (legacy flat lists) or `ordered` (typed stop list).
2128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2129#[serde(rename_all = "snake_case")]
2130pub enum WorkerRouteKindView {
2131    #[default]
2132    HarvestLoop,
2133    Ordered,
2134}
2135
2136/// Saved worker route for UI / route editor reload.
2137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
2138pub struct WorkerRouteView {
2139    #[serde(default)]
2140    pub kind: WorkerRouteKindView,
2141    #[serde(default)]
2142    pub lodging_container_id: Option<String>,
2143    /// Legacy `harvest_loop` waypoint list.
2144    #[serde(default)]
2145    pub outbound_waypoints: Vec<WorkerRouteWaypointView>,
2146    /// Legacy `harvest_loop` node id list.
2147    #[serde(default)]
2148    pub harvest_nodes: Vec<String>,
2149    #[serde(default = "default_route_carry_ratio")]
2150    pub carry_return_ratio: f32,
2151    /// Ordered-route typed stops (`kind: ordered`).
2152    #[serde(default)]
2153    pub stops: Vec<WorkerRouteStopView>,
2154}
2155
2156fn default_route_carry_ratio() -> f32 {
2157    0.90
2158}
2159
2160fn default_true_view() -> bool {
2161    true
2162}
2163
2164/// One withdraw line for a `WithdrawFrom` route stop view.
2165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2166pub struct WorkerWithdrawItemView {
2167    pub template: String,
2168    /// Hold-up-to count (top-up each loop). Ignored when `all` is set.
2169    #[serde(default)]
2170    pub qty: u32,
2171    /// Take every stack of this template (carry-capped). Wins over `qty`.
2172    #[serde(default)]
2173    pub all: bool,
2174}
2175
2176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
2177pub struct WorkerRouteWaypointView {
2178    pub x: f32,
2179    pub y: f32,
2180    pub z: f32,
2181}
2182
2183/// One typed stop in an ordered worker route.
2184///
2185/// NOTE: this is the wire (postcard) view. Postcard does **not** support
2186/// internally-tagged enums (`#[serde(tag = ...)]` — it returns `WontImplement`
2187/// from `deserialize_any`), so this enum uses serde's default **external tagging**.
2188/// The sim-side `WorkerRouteStop` (`crates/sim/src/worker_job.rs`) is the YAML-facing
2189/// twin and keeps its `tag = "stop"` for human-authored job YAML; the two never share
2190/// a wire format.
2191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2192#[serde(rename_all = "snake_case")]
2193pub enum WorkerRouteStopView {
2194    Waypoint {
2195        x: f32,
2196        y: f32,
2197        #[serde(default)]
2198        z: f32,
2199    },
2200    HarvestNode {
2201        node_id: String,
2202    },
2203    DepositAt {
2204        container_id: String,
2205        #[serde(default)]
2206        filter: Option<Vec<String>>,
2207    },
2208    TradeWith {
2209        #[serde(default)]
2210        npc_id: Option<String>,
2211        template: String,
2212        #[serde(default = "default_true_view")]
2213        sell_all: bool,
2214    },
2215    WithdrawFrom {
2216        container_id: String,
2217        items: Vec<WorkerWithdrawItemView>,
2218    },
2219    CraftAt {
2220        device: String,
2221        blueprint: String,
2222        #[serde(default)]
2223        qty: Option<u32>,
2224    },
2225    CultivatePlot {
2226        plot_id: uuid::Uuid,
2227    },
2228    PlantPlot {
2229        plot_id: uuid::Uuid,
2230        seed_template: String,
2231    },
2232    HarvestPlot {
2233        plot_id: uuid::Uuid,
2234    },
2235    RestIfNeeded,
2236    Wait {
2237        #[serde(default)]
2238        wait_ticks: u64,
2239    },
2240}
2241
2242/// Copper ledger category (expense negative / income positive on the wire entry).
2243#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2244#[serde(rename_all = "snake_case")]
2245pub enum LedgerCategory {
2246    Workers,
2247    Hire,
2248    Train,
2249    ShopBuy,
2250    Taxes,
2251    WorkerSales,
2252    TraderSales,
2253    BankDeposit,
2254    BankWithdraw,
2255    BankTransferOut,
2256    BankTransferIn,
2257    BankTransferFee,
2258    StorageShipFee,
2259    /// Crown or private purchase of a property deed.
2260    PropertyBuy,
2261    /// Crown buyback or private sale of a property deed.
2262    PropertySell,
2263    /// Landlord share of harvest tax paid on an owned plot.
2264    TaxShare,
2265    /// Bank debit for a market-hall purchase (`plans/10-economy-and-markets.md`).
2266    MarketBuy,
2267    /// Bank credit for a market-hall sale (net of crown sales tax).
2268    MarketSell,
2269    Other,
2270}
2271
2272impl LedgerCategory {
2273    pub fn as_str(self) -> &'static str {
2274        match self {
2275            Self::Workers => "workers",
2276            Self::Hire => "hire",
2277            Self::Train => "train",
2278            Self::ShopBuy => "shop_buy",
2279            Self::Taxes => "taxes",
2280            Self::WorkerSales => "worker_sales",
2281            Self::TraderSales => "trader_sales",
2282            Self::BankDeposit => "bank_deposit",
2283            Self::BankWithdraw => "bank_withdraw",
2284            Self::BankTransferOut => "bank_transfer_out",
2285            Self::BankTransferIn => "bank_transfer_in",
2286            Self::BankTransferFee => "bank_transfer_fee",
2287            Self::StorageShipFee => "storage_ship_fee",
2288            Self::PropertyBuy => "property_buy",
2289            Self::PropertySell => "property_sell",
2290            Self::TaxShare => "tax_share",
2291            Self::MarketBuy => "market_buy",
2292            Self::MarketSell => "market_sell",
2293            Self::Other => "other",
2294        }
2295    }
2296}
2297
2298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2299pub struct LedgerEntryView {
2300    pub id: uuid::Uuid,
2301    pub game_day: u64,
2302    pub signed_copper: i64,
2303    pub category: LedgerCategory,
2304    #[serde(default)]
2305    pub label: String,
2306}
2307
2308#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
2309pub struct LedgerPeriodTotals {
2310    /// Absolute copper spent by category key.
2311    #[serde(default)]
2312    pub expenses: std::collections::HashMap<String, u64>,
2313    /// Absolute copper earned by category key.
2314    #[serde(default)]
2315    pub income: std::collections::HashMap<String, u64>,
2316    pub expense_copper: u64,
2317    pub income_copper: u64,
2318    /// income − expenses (may be negative).
2319    pub cash_flow_copper: i64,
2320}
2321
2322#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
2323pub struct PlayerLedgerView {
2324    pub current_game_day: u64,
2325    #[serde(default)]
2326    pub period_day: LedgerPeriodTotals,
2327    #[serde(default)]
2328    pub period_week: LedgerPeriodTotals,
2329    #[serde(default)]
2330    pub period_month: LedgerPeriodTotals,
2331    #[serde(default)]
2332    pub period_lifetime: LedgerPeriodTotals,
2333    #[serde(default)]
2334    pub recent: Vec<LedgerEntryView>,
2335    /// Copper on the character (inventory + worn bags).
2336    #[serde(default)]
2337    pub wealth_on_person_copper: u64,
2338    /// Copper in owned placed storage (chests, lodging — not bank ledger).
2339    #[serde(default)]
2340    pub wealth_in_storage_copper: u64,
2341    /// Copper in the secure bank ledger (`plans/08` §8).
2342    #[serde(default)]
2343    pub wealth_in_bank_copper: u64,
2344    /// `wealth_on_person_copper + wealth_in_storage_copper + wealth_in_bank_copper`.
2345    #[serde(default)]
2346    pub wealth_total_copper: u64,
2347    /// Sum of purchase-basis copper for deeds this character currently holds (asset book value).
2348    #[serde(default)]
2349    pub wealth_in_property_copper: u64,
2350    /// Liquid copper + property book value.
2351    #[serde(default)]
2352    pub wealth_net_worth_copper: u64,
2353    /// Deeds held (inventory, worn bags, town vault, owned chests) with book values.
2354    #[serde(default)]
2355    pub property_assets: Vec<PropertyAssetView>,
2356    /// Recent property sales near plots you hold (comps for a local market).
2357    #[serde(default)]
2358    pub property_market_nearby: Vec<PropertyMarketCompView>,
2359    /// Live payroll burn (cp per wage interval) for hired workers.
2360    #[serde(default)]
2361    pub live_expense_per_interval_copper: u64,
2362    /// Estimated copper from one full worker job loop (broker sell steps).
2363    #[serde(default)]
2364    pub live_income_route_est_per_loop_copper: u64,
2365    /// Recent average income (worker/trader sales) per wage interval.
2366    #[serde(default)]
2367    pub live_income_avg_per_interval_copper: u64,
2368    /// Number of wage intervals in the rolling average window.
2369    #[serde(default)]
2370    pub live_income_avg_window_intervals: u32,
2371    /// `live_income_avg_per_interval_copper − live_expense_per_interval_copper`.
2372    #[serde(default)]
2373    pub live_net_avg_per_interval_copper: i64,
2374}
2375
2376/// One deed currently held by the character (ledger asset line).
2377#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2378pub struct PropertyAssetView {
2379    pub plot_id: Uuid,
2380    /// Friendly deed label (`{Owner}'s {Zone} deed @ (x, y) (N m²)`).
2381    pub label: String,
2382    pub zone_id: String,
2383    #[serde(default)]
2384    pub zone_label: Option<String>,
2385    pub area_m2: f32,
2386    /// Book value (what you paid / last private sale price).
2387    pub purchase_basis_copper: u64,
2388    pub upkeep_copper_per_day: u64,
2389}
2390
2391/// A recorded property sale near the observer's holdings.
2392#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2393pub struct PropertyMarketCompView {
2394    pub day: u64,
2395    pub zone_id: String,
2396    #[serde(default)]
2397    pub zone_label: Option<String>,
2398    pub area_m2: f32,
2399    pub price_copper: u64,
2400    /// `price_copper / area_m2` (0 when area is tiny).
2401    pub price_per_m2_copper: u64,
2402    /// `crown_purchase` | `crown_buyback` | `player_trade`.
2403    pub kind: String,
2404    /// Distance from the nearest plot you hold (meters).
2405    pub distance_m: f32,
2406}
2407
2408/// Gameplay analytics metric keys (extensible string on the wire via rename).
2409#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2410#[serde(rename_all = "snake_case")]
2411pub enum AnalyticsMetric {
2412    NpcKill,
2413    WildlifeKill,
2414    Harvest,
2415    QuestComplete,
2416    QuestAccept,
2417    QuestAbandon,
2418    PlayerDeath,
2419    Craft,
2420    WorkerHire,
2421    WorkerDismiss,
2422    WorkerTeach,
2423    NpcTalk,
2424    ShopBuy,
2425    ShopSell,
2426    PlaceContainer,
2427    PickupContainer,
2428    PickupDrop,
2429    ConsumableUse,
2430    AbilityUse,
2431    DistanceWalkedM,
2432    DoorUse,
2433    BuildingEnter,
2434}
2435
2436impl AnalyticsMetric {
2437    pub fn as_str(self) -> &'static str {
2438        match self {
2439            Self::NpcKill => "npc_kill",
2440            Self::WildlifeKill => "wildlife_kill",
2441            Self::Harvest => "harvest",
2442            Self::QuestComplete => "quest_complete",
2443            Self::QuestAccept => "quest_accept",
2444            Self::QuestAbandon => "quest_abandon",
2445            Self::PlayerDeath => "player_death",
2446            Self::Craft => "craft",
2447            Self::WorkerHire => "worker_hire",
2448            Self::WorkerDismiss => "worker_dismiss",
2449            Self::WorkerTeach => "worker_teach",
2450            Self::NpcTalk => "npc_talk",
2451            Self::ShopBuy => "shop_buy",
2452            Self::ShopSell => "shop_sell",
2453            Self::PlaceContainer => "place_container",
2454            Self::PickupContainer => "pickup_container",
2455            Self::PickupDrop => "pickup_drop",
2456            Self::ConsumableUse => "consumable_use",
2457            Self::AbilityUse => "ability_use",
2458            Self::DistanceWalkedM => "distance_walked_m",
2459            Self::DoorUse => "door_use",
2460            Self::BuildingEnter => "building_enter",
2461        }
2462    }
2463
2464    pub fn from_str_key(s: &str) -> Option<Self> {
2465        Some(match s {
2466            "npc_kill" => Self::NpcKill,
2467            "wildlife_kill" => Self::WildlifeKill,
2468            "harvest" => Self::Harvest,
2469            "quest_complete" => Self::QuestComplete,
2470            "quest_accept" => Self::QuestAccept,
2471            "quest_abandon" => Self::QuestAbandon,
2472            "player_death" => Self::PlayerDeath,
2473            "craft" => Self::Craft,
2474            "worker_hire" => Self::WorkerHire,
2475            "worker_dismiss" => Self::WorkerDismiss,
2476            "worker_teach" => Self::WorkerTeach,
2477            "npc_talk" => Self::NpcTalk,
2478            "shop_buy" => Self::ShopBuy,
2479            "shop_sell" => Self::ShopSell,
2480            "place_container" => Self::PlaceContainer,
2481            "pickup_container" => Self::PickupContainer,
2482            "pickup_drop" => Self::PickupDrop,
2483            "consumable_use" => Self::ConsumableUse,
2484            "ability_use" => Self::AbilityUse,
2485            "distance_walked_m" => Self::DistanceWalkedM,
2486            "door_use" => Self::DoorUse,
2487            "building_enter" => Self::BuildingEnter,
2488            _ => return None,
2489        })
2490    }
2491}
2492
2493#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2494pub struct CareerMetricRow {
2495    pub subject_id: String,
2496    pub amount: u64,
2497}
2498
2499/// Personal analytics summary for the Character `i` Career tab.
2500#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
2501pub struct PlayerCareerView {
2502    pub current_game_day: u64,
2503    #[serde(default)]
2504    pub kills: Vec<CareerMetricRow>,
2505    #[serde(default)]
2506    pub harvests: Vec<CareerMetricRow>,
2507    pub quests_completed: u64,
2508    #[serde(default)]
2509    pub crafts: Vec<CareerMetricRow>,
2510    pub deaths: u64,
2511    pub npc_talks: u64,
2512    pub shop_buys: u64,
2513    pub shop_sells: u64,
2514    pub distance_m: u64,
2515    #[serde(default)]
2516    pub other: Vec<CareerMetricRow>,
2517}
2518
2519/// Equipment currently worn by a player-hired worker.
2520///
2521/// Equipped items are separate from `HiredWorkerView::inventory`; the latter
2522/// contains only the worker's loose/root pack contents.
2523#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
2524pub struct WorkerEquipmentView {
2525    #[serde(default)]
2526    pub mainhand: Option<ItemStack>,
2527    #[serde(default)]
2528    pub offhand: Option<ItemStack>,
2529    #[serde(default)]
2530    pub worn: Vec<(BodySlot, ItemStack)>,
2531}
2532
2533/// One player-hired worker visible in snapshot / tick deltas.
2534#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2535pub struct HiredWorkerView {
2536    pub instance_id: String,
2537    pub entity_id: EntityId,
2538    pub def_id: String,
2539    /// Display label (custom name when set, otherwise the NPC def label).
2540    pub label: String,
2541    pub x: f32,
2542    pub y: f32,
2543    pub z: f32,
2544    pub mode: WorkerModeView,
2545    pub state: WorkerStateView,
2546    #[serde(default)]
2547    pub step_label: String,
2548    pub vitals: WorkerVitalsSummary,
2549    #[serde(default)]
2550    pub carry_pct: f32,
2551    #[serde(default)]
2552    pub last_error: Option<String>,
2553    pub wage_copper_per_interval: u32,
2554    /// Estimated wage this interval (base + loop effort, travel meters excluded).
2555    #[serde(default)]
2556    pub effective_wage_copper: u32,
2557    /// Meters walked toward the next wage debit.
2558    #[serde(default)]
2559    pub wage_meters_walked: f32,
2560    /// Placed camp bed / lodging container this worker uses for deposit and rest.
2561    #[serde(default)]
2562    pub lodging_container_id: Option<String>,
2563    /// High-level harvest route (when job_loop route is configured).
2564    #[serde(default)]
2565    pub route: Option<WorkerRouteView>,
2566    /// Index into `route.stops` for the worker's current job step (ordered routes).
2567    /// Maps expanded job steps (travel+deposit, etc.) back to the designer stop.
2568    #[serde(default)]
2569    pub route_stop_index: Option<u32>,
2570    /// Recipes this worker already knows (from hire `teaches` + employer teach).
2571    #[serde(default)]
2572    pub known_blueprint_ids: Vec<String>,
2573    /// Overall worker level (1+).
2574    #[serde(default = "default_worker_view_level")]
2575    pub level: u32,
2576    /// Cumulative worker XP.
2577    #[serde(default)]
2578    pub worker_xp: f64,
2579    /// Items the worker currently carries (employer-visible for give/take).
2580    #[serde(default)]
2581    pub inventory: Vec<ItemStack>,
2582    /// Items currently equipped by the worker, separate from `inventory`.
2583    #[serde(default)]
2584    pub equipment: WorkerEquipmentView,
2585    /// Short "what you should do" line when `last_error` is a player-actionable
2586    /// plan or logistics issue (missing chest, empty lodging, etc.).
2587    #[serde(default)]
2588    pub issue_hint: Option<String>,
2589}
2590
2591fn default_worker_view_level() -> u32 {
2592    1
2593}
2594
2595/// Server → client AOI-filtered entity updates for one sim tick.
2596#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2597pub struct TickDelta {
2598    pub tick: Tick,
2599    pub entities: Vec<EntityState>,
2600    #[serde(default)]
2601    pub resource_nodes: Vec<ResourceNodeView>,
2602    #[serde(default)]
2603    pub buildings: Vec<BuildingView>,
2604    #[serde(default)]
2605    pub doors: Vec<DoorView>,
2606    #[serde(default)]
2607    pub npcs: Vec<NpcView>,
2608    /// Observer inventory stacks (template → qty).
2609    #[serde(default)]
2610    pub inventory: Vec<ItemStack>,
2611    #[serde(default)]
2612    pub blueprints: Vec<BlueprintView>,
2613    /// Wall/roof material packs for the B plot-build menu.
2614    #[serde(default)]
2615    pub building_materials: Vec<BuildingMaterialView>,
2616    #[serde(default)]
2617    pub world_clock: WorldClock,
2618    #[serde(default)]
2619    pub ground_drops: Vec<GroundDropView>,
2620    #[serde(default)]
2621    pub placed_containers: Vec<PlacedContainerView>,
2622    #[serde(default)]
2623    pub combat: Option<CombatHud>,
2624    #[serde(default)]
2625    pub interior_map: Option<InteriorMapView>,
2626    #[serde(default)]
2627    pub quest_log: Vec<QuestLogEntry>,
2628    #[serde(default)]
2629    pub hired_workers: Vec<HiredWorkerView>,
2630    #[serde(default)]
2631    pub interactables: Vec<InteractableView>,
2632    #[serde(default)]
2633    pub ledger: Option<PlayerLedgerView>,
2634    #[serde(default)]
2635    pub career: Option<PlayerCareerView>,
2636    /// Active combat footprints / hit markers in observer AOI (`plans/39`).
2637    #[serde(default)]
2638    pub combat_fx: Vec<CombatFx>,
2639    /// Persistent ground hazards (boss puddles) in observer AOI.
2640    #[serde(default)]
2641    pub ground_hazards: Vec<GroundHazardView>,
2642    /// Claimed property plots near the observer (plan 40).
2643    #[serde(default)]
2644    pub property_plots: Vec<PropertyPlotView>,
2645    /// Runtime terrain cell overlays (cultivate dirt → tilled). Replaces prior overlays.
2646    #[serde(default)]
2647    pub terrain_overlays: Vec<TerrainZoneView>,
2648}
2649#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2650pub struct GroundDropView {
2651    pub id: String,
2652    pub template_id: String,
2653    pub quantity: u32,
2654    pub x: f32,
2655    pub y: f32,
2656    pub z: f32,
2657    /// Optional gfx tile id from the item template.
2658    #[serde(default)]
2659    pub tile_id: Option<String>,
2660    /// Item display name (e.g. "Cloth Pants") for hover/chat labels.
2661    #[serde(default)]
2662    pub display_name: Option<String>,
2663    /// Facing yaw in radians (0 = north) for sprite draw on the ground.
2664    #[serde(default)]
2665    pub yaw: f32,
2666    /// Pitch in radians (0 = upright).
2667    #[serde(default)]
2668    pub pitch: f32,
2669    /// Roll in radians (0 = upright; tilts in the view plane).
2670    #[serde(default)]
2671    pub roll: f32,
2672    /// Draw scale relative to one map cell (1.0 = cell size).
2673    #[serde(default = "default_draw_scale")]
2674    pub draw_scale: f32,
2675}
2676
2677fn default_draw_scale() -> f32 {
2678    1.0
2679}
2680
2681/// Full state on region enter or reconnect.
2682#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2683pub struct Snapshot {
2684    pub tick: Tick,
2685    pub chunk_rev: u64,
2686    /// Bumped when blueprints, catalog, segment, or settings reload.
2687    #[serde(default)]
2688    pub content_rev: u64,
2689    /// Stable publish revision from `assets/.content-publish.json` (for client asset sync).
2690    #[serde(default)]
2691    pub publish_rev: u64,
2692    pub entities: Vec<EntityState>,
2693    #[serde(default)]
2694    pub resource_nodes: Vec<ResourceNodeView>,
2695    /// Outdoor play AABB origin (meters). With width/height, defines the composed world.
2696    #[serde(default)]
2697    pub world_x0: f32,
2698    #[serde(default)]
2699    pub world_y0: f32,
2700    /// Segment play area width in meters (for HUD / beyond-zone).
2701    #[serde(default)]
2702    pub world_width_m: f32,
2703    #[serde(default)]
2704    pub world_height_m: f32,
2705    #[serde(default)]
2706    pub buildings: Vec<BuildingView>,
2707    #[serde(default)]
2708    pub doors: Vec<DoorView>,
2709    #[serde(default)]
2710    pub npcs: Vec<NpcView>,
2711    #[serde(default)]
2712    pub inventory: Vec<ItemStack>,
2713    #[serde(default)]
2714    pub blueprints: Vec<BlueprintView>,
2715    /// Wall/roof material packs for the B plot-build menu.
2716    #[serde(default)]
2717    pub building_materials: Vec<BuildingMaterialView>,
2718    #[serde(default)]
2719    pub world_clock: WorldClock,
2720    #[serde(default)]
2721    pub terrain_zones: Vec<TerrainZoneView>,
2722    #[serde(default)]
2723    pub z_platforms: Vec<ZPlatformView>,
2724    #[serde(default)]
2725    pub z_transitions: Vec<ZTransitionView>,
2726    #[serde(default)]
2727    pub ground_drops: Vec<GroundDropView>,
2728    #[serde(default)]
2729    pub placed_containers: Vec<PlacedContainerView>,
2730    #[serde(default)]
2731    pub combat: Option<CombatHud>,
2732    #[serde(default)]
2733    pub interior_map: Option<InteriorMapView>,
2734    #[serde(default)]
2735    pub quest_log: Vec<QuestLogEntry>,
2736    #[serde(default)]
2737    pub hired_workers: Vec<HiredWorkerView>,
2738    #[serde(default)]
2739    pub interactables: Vec<InteractableView>,
2740    #[serde(default)]
2741    pub ledger: Option<PlayerLedgerView>,
2742    #[serde(default)]
2743    pub career: Option<PlayerCareerView>,
2744    /// Active combat footprints / hit markers (`plans/39`).
2745    #[serde(default)]
2746    pub combat_fx: Vec<CombatFx>,
2747    /// Persistent ground hazards (boss puddles) in observer AOI.
2748    #[serde(default)]
2749    pub ground_hazards: Vec<GroundHazardView>,
2750    /// Authored crown property zones (claimable land) — plan 40.
2751    #[serde(default)]
2752    pub property_zones: Vec<PropertyZoneView>,
2753    /// Tax overlays (for claim cost premium preview).
2754    #[serde(default)]
2755    pub tax_zones: Vec<TaxZoneView>,
2756    /// Town / security / PvP boundaries (plan 43).
2757    #[serde(default)]
2758    pub boundary_zones: Vec<BoundaryZoneView>,
2759    /// Random-encounter rectangles (plan 54).
2760    #[serde(default)]
2761    pub encounter_zones: Vec<EncounterZoneView>,
2762    /// Growth / fertility overlays (plan 37 / farming).
2763    #[serde(default)]
2764    pub growth_zones: Vec<GrowthZoneView>,
2765    /// Climate / biome overlays (plan 38).
2766    #[serde(default)]
2767    pub biome_zones: Vec<BiomeZoneView>,
2768    /// Terrain kind move speed / impassable table for client pathfinding (from terrain-kinds.yaml).
2769    #[serde(default)]
2770    pub terrain_kind_nav: Vec<TerrainKindNavView>,
2771    /// Claimed property plots in this segment.
2772    #[serde(default)]
2773    pub property_plots: Vec<PropertyPlotView>,
2774    /// Server knobs needed for client claim quote preview.
2775    #[serde(default)]
2776    pub property_plot_settings: Option<PropertyPlotSettingsView>,
2777}
2778
2779/// Harvestable node visible to clients.
2780#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2781pub struct ResourceNodeView {
2782    pub id: String,
2783    pub label: String,
2784    pub x: f32,
2785    pub y: f32,
2786    pub z: f32,
2787    pub item_template: String,
2788    #[serde(default = "default_node_state")]
2789    pub state: ResourceNodeState,
2790    /// When true, players cannot walk through this node while available/harvesting.
2791    #[serde(default = "default_blocking_view")]
2792    pub blocking: bool,
2793    /// Collision radius for pathfinding (meters).
2794    #[serde(default = "default_blocking_radius_view")]
2795    pub blocking_radius_m: f32,
2796    /// Decorative map placement — not harvestable; `blocking` is forced false on the wire.
2797    #[serde(default)]
2798    pub harvest_off: bool,
2799    /// Optional gfx tile id (`resource.oak_log`, …).
2800    #[serde(default)]
2801    pub tile_id: Option<String>,
2802    /// Facing yaw in radians (0 = north). From map placement.
2803    #[serde(default)]
2804    pub yaw: f32,
2805    /// Pitch in radians (0 = upright). From map placement.
2806    #[serde(default)]
2807    pub pitch: f32,
2808    /// Roll in radians (0 = upright). From map placement.
2809    #[serde(default)]
2810    pub roll: f32,
2811    /// Draw scale relative to one map cell (1.0 = cell size).
2812    #[serde(default = "default_draw_scale")]
2813    pub draw_scale: f32,
2814    /// Resolved gfx sprite mode for `tile_id` (server-computed).
2815    #[serde(default)]
2816    pub sprite_mode: Option<String>,
2817    /// Canonical presentation key (`available`, `harvesting`, `depleted`).
2818    #[serde(default)]
2819    pub presentation_state: Option<String>,
2820    /// Farm crop growth 0.0–1.0 while immature; `None` for other nodes and mature crops.
2821    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
2822    #[serde(default)]
2823    pub growth_progress: Option<f32>,
2824    /// Active harvest channel (sim ticks), for progress rings on the node.
2825    #[serde(default)]
2826    pub channel_start_tick: Option<Tick>,
2827    #[serde(default)]
2828    pub channel_end_tick: Option<Tick>,
2829    /// Possible loot templates from this node's harvest loot table (route deposit filters).
2830    #[serde(default)]
2831    pub harvest_drop_templates: Vec<String>,
2832}
2833
2834fn default_blocking_radius_view() -> f32 {
2835    0.8
2836}
2837
2838fn default_blocking_view() -> bool {
2839    true
2840}
2841
2842#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2843#[serde(rename_all = "snake_case")]
2844pub enum ResourceNodeState {
2845    Available,
2846    Harvesting,
2847    Cooldown,
2848}
2849fn default_node_state() -> ResourceNodeState {
2850    ResourceNodeState::Available
2851}
2852
2853/// Live state of a placed world item spawn (plan 45).
2854#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2855#[serde(rename_all = "snake_case")]
2856pub enum ItemSpawnStateView {
2857    Spawned,
2858    PickedUp { respawn_at_tick: u64 },
2859    Consumed,
2860}
2861
2862/// Authoritative view of a placed findable item (plan 45) for admin/overseer.
2863#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2864pub struct ItemSpawnView {
2865    pub id: String,
2866    pub label: String,
2867    pub item_template: String,
2868    pub quantity: u32,
2869    pub x: f32,
2870    pub y: f32,
2871    pub z: f32,
2872    pub respawn_ticks: u32,
2873    #[serde(default)]
2874    pub building_id: Option<String>,
2875    pub state: ItemSpawnStateView,
2876}
2877
2878#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2879#[serde(rename_all = "snake_case")]
2880pub enum ItemStatusBindingMode {
2881    OnHit,
2882    WhileEquipped,
2883}
2884
2885impl Default for ItemStatusBindingMode {
2886    fn default() -> Self {
2887        Self::OnHit
2888    }
2889}
2890
2891/// Per-instance status effect binding (unique gear grants / enchantments).
2892#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2893pub struct ItemStatusBinding {
2894    pub effect_id: String,
2895    #[serde(default)]
2896    pub mode: ItemStatusBindingMode,
2897    /// Grant template id, `"loot"`, later `"altar"`, etc.
2898    #[serde(default)]
2899    pub source: String,
2900    #[serde(default)]
2901    pub applied_at_tick: u64,
2902    /// `None` = permanent until overwritten / dispelled.
2903    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
2904    #[serde(default)]
2905    pub expires_at_tick: Option<u64>,
2906}
2907
2908#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2909pub struct ItemStack {
2910    pub template_id: String,
2911    pub quantity: u32,
2912    /// Stable instance id — preserved across checkpoint/sync when set.
2913    #[serde(default)]
2914    pub item_instance_id: Option<Uuid>,
2915    /// Per-instance metadata (stat rolls, soul-bind, lock ids, etc.).
2916    #[serde(default)]
2917    pub props: BTreeMap<String, String>,
2918    /// Instance-only status effects (template effects live on the item def).
2919    #[serde(default)]
2920    pub status_bindings: Vec<ItemStatusBinding>,
2921    /// Nested contents when this stack is a container instance.
2922    #[serde(default)]
2923    pub contents: Vec<ItemStack>,
2924    /// From item catalog when sent on wire (display only).
2925    #[serde(default)]
2926    pub display_name: Option<String>,
2927    /// From item catalog when sent on wire (`weapon`, `consumable`, …).
2928    #[serde(default)]
2929    pub category: Option<String>,
2930    /// Per-unit mass in kg from catalog (display / move UX).
2931    #[serde(default)]
2932    pub base_mass: Option<f32>,
2933    /// Per-unit volume from catalog (display / move UX).
2934    #[serde(default)]
2935    pub base_volume: Option<f32>,
2936    /// Container capacity when this stack is a container template.
2937    #[serde(default)]
2938    pub capacity_volume: Option<f32>,
2939    /// Whether the template stacks in inventory (from catalog).
2940    #[serde(default)]
2941    pub stackable: Option<bool>,
2942    /// Can be placed on the ground from inventory (from catalog).
2943    #[serde(default)]
2944    pub world_placeable: Option<bool>,
2945    /// Hired-worker lodging capacity when this template is placed (from catalog).
2946    #[serde(default)]
2947    pub worker_lodging_capacity: Option<u32>,
2948    /// Body slot this template equips into (from catalog; display / Equip UI).
2949    #[serde(default)]
2950    pub equip_slot: Option<BodySlot>,
2951    /// Template baseline armor rating (from catalog).
2952    #[serde(default)]
2953    pub armor_physical: Option<f32>,
2954    /// Template resists damage_type → value (from catalog).
2955    #[serde(default)]
2956    pub resists: Vec<(String, f32)>,
2957    /// Weapon hand occupancy when category is weapon (`1` or `2`).
2958    #[serde(default)]
2959    pub hand_slots: Option<u8>,
2960    /// Market-hall list eligibility from catalog (display / client pickers).
2961    #[serde(default)]
2962    pub listable: Option<bool>,
2963    /// Catalog NPC trade base (`base_value_copper`) for dump-queue payout hints.
2964    #[serde(default)]
2965    pub base_value_copper: Option<u32>,
2966}
2967
2968impl ItemStack {
2969    pub fn simple(template_id: impl Into<String>, quantity: u32) -> Self {
2970        Self {
2971            template_id: template_id.into(),
2972            quantity,
2973            ..Default::default()
2974        }
2975    }
2976}
2977
2978impl Default for ItemStack {
2979    fn default() -> Self {
2980        Self {
2981            template_id: String::new(),
2982            quantity: 0,
2983            item_instance_id: None,
2984            props: BTreeMap::new(),
2985            status_bindings: Vec::new(),
2986            contents: Vec::new(),
2987            display_name: None,
2988            category: None,
2989            base_mass: None,
2990            base_volume: None,
2991            capacity_volume: None,
2992            stackable: None,
2993            world_placeable: None,
2994            worker_lodging_capacity: None,
2995            equip_slot: None,
2996            armor_physical: None,
2997            resists: Vec::new(),
2998            hand_slots: None,
2999            listable: None,
3000            base_value_copper: None,
3001        }
3002    }
3003}
3004
3005/// Carry encumbrance band (`plans/08` §3.2).
3006#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3007#[serde(rename_all = "snake_case")]
3008pub enum EncumbranceState {
3009    #[default]
3010    Light,
3011    Heavy,
3012    Over,
3013}
3014
3015/// Body region a wearable item occupies — at most one item equipped per slot.
3016/// Armor, cloak, jewelry, and carriers (`Back` backpack, `Waist` belt). Hands
3017/// (mainhand / offhand) are separate combat sockets — not `BodySlot`.
3018#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)]
3019#[serde(rename_all = "snake_case")]
3020pub enum BodySlot {
3021    Head,
3022    /// Chest / torso armor. Serde alias `body` for older YAML / saves.
3023    #[serde(alias = "body")]
3024    Chest,
3025    /// Gauntlets / bracers / sleeves. Serde alias `arms` for older YAML / saves.
3026    #[serde(alias = "arms")]
3027    Forearms,
3028    Legs,
3029    Feet,
3030    Cloak,
3031    Back,
3032    Waist,
3033    Earrings,
3034    Necklace,
3035    Eyeglasses,
3036    /// Explicit rename: plain `snake_case` would be `ring_left1`.
3037    #[serde(rename = "ring_left_1", alias = "ring_left1")]
3038    RingLeft1,
3039    #[serde(rename = "ring_left_2", alias = "ring_left2")]
3040    RingLeft2,
3041    #[serde(rename = "ring_right_1", alias = "ring_right1")]
3042    RingRight1,
3043    #[serde(rename = "ring_right_2", alias = "ring_right2")]
3044    RingRight2,
3045}
3046
3047impl BodySlot {
3048    /// All worn slots in paperdoll / catalog order.
3049    pub const ALL: [BodySlot; 15] = [
3050        BodySlot::Head,
3051        BodySlot::Chest,
3052        BodySlot::Forearms,
3053        BodySlot::Legs,
3054        BodySlot::Feet,
3055        BodySlot::Cloak,
3056        BodySlot::Back,
3057        BodySlot::Waist,
3058        BodySlot::Earrings,
3059        BodySlot::Necklace,
3060        BodySlot::Eyeglasses,
3061        BodySlot::RingLeft1,
3062        BodySlot::RingLeft2,
3063        BodySlot::RingRight1,
3064        BodySlot::RingRight2,
3065    ];
3066
3067    pub fn as_str(self) -> &'static str {
3068        match self {
3069            BodySlot::Head => "head",
3070            BodySlot::Chest => "chest",
3071            BodySlot::Forearms => "forearms",
3072            BodySlot::Legs => "legs",
3073            BodySlot::Feet => "feet",
3074            BodySlot::Cloak => "cloak",
3075            BodySlot::Back => "back",
3076            BodySlot::Waist => "waist",
3077            BodySlot::Earrings => "earrings",
3078            BodySlot::Necklace => "necklace",
3079            BodySlot::Eyeglasses => "eyeglasses",
3080            BodySlot::RingLeft1 => "ring_left_1",
3081            BodySlot::RingLeft2 => "ring_left_2",
3082            BodySlot::RingRight1 => "ring_right_1",
3083            BodySlot::RingRight2 => "ring_right_2",
3084        }
3085    }
3086}
3087
3088/// Where an item lives for `MoveItem` / open-container UX.
3089#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3090#[serde(rename_all = "snake_case")]
3091pub enum InventoryLocation {
3092    /// Loose on-person inventory (not inside a worn/placed container).
3093    Root,
3094    /// Inside a worn item (backpack contents, or a pouch clipped onto a worn belt).
3095    Worn { slot: BodySlot },
3096    /// Inside a world-placed container.
3097    Placed { container_id: String },
3098    /// Virtual key ring — only `container_key` items; zero carry mass; persists with combat profile.
3099    Keychain,
3100    /// Virtual whisper pouch — only `whisper_stone` items; zero carry mass.
3101    WhisperPouch,
3102}
3103
3104/// AOI view of a placeable chest on the map.
3105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3106pub struct PlacedContainerView {
3107    pub id: String,
3108    pub template_id: String,
3109    pub display_name: String,
3110    pub x: f32,
3111    pub y: f32,
3112    pub z: f32,
3113    pub locked: bool,
3114    /// Observer can open (unlocked, or holds matching key).
3115    #[serde(default)]
3116    pub accessible: bool,
3117    #[serde(default)]
3118    pub owner_character_id: Option<Uuid>,
3119    /// Nested contents when `accessible` (empty when locked without key).
3120    #[serde(default)]
3121    pub contents: Vec<ItemStack>,
3122    /// Lock id for matching `container_key` props (`opens_lock_id`).
3123    #[serde(default)]
3124    pub lock_id: Option<String>,
3125    /// Internal storage capacity (liters) from item catalog.
3126    #[serde(default)]
3127    pub capacity_volume: Option<f32>,
3128    /// Container instance id (for MoveItem parent targeting).
3129    #[serde(default)]
3130    pub item_instance_id: Option<Uuid>,
3131    /// Optional gfx tile id from the item template.
3132    #[serde(default)]
3133    pub tile_id: Option<String>,
3134    /// Hired-worker slots when this is placed lodging (`category: lodging`).
3135    #[serde(default)]
3136    pub worker_lodging_capacity: Option<u32>,
3137    /// From item template — blocks movement and autopath when placed.
3138    #[serde(default)]
3139    pub blocking: bool,
3140    /// Collision radius (m) for pathfinding when `blocking`.
3141    #[serde(default)]
3142    pub blocking_radius_m: f32,
3143    /// Interior space when placed indoors (`None` = outdoors). Postcard always
3144    /// serializes optionals so decode stays aligned — bump `PROTOCOL_VERSION`.
3145    #[serde(default)]
3146    pub building_id: Option<String>,
3147}
3148
3149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3150pub struct BlueprintIngredientView {
3151    pub template_id: String,
3152    pub quantity: u32,
3153    /// Always serialize — `true` is not bool::default() so postcard keeps it; explicit for clarity.
3154    pub consumed: bool,
3155    /// Catalog display name for UI (never show bare template_id when this is set).
3156    #[serde(default)]
3157    pub display_name: String,
3158}
3159
3160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3161pub struct ToolRequirementView {
3162    pub item: String,
3163    /// Always serialize — postcard omits `false` by default, which breaks roundtrip without explicit value.
3164    pub consumed: bool,
3165    /// Catalog display name for UI.
3166    #[serde(default)]
3167    pub display_name: String,
3168}
3169
3170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3171pub struct SkillRequirementView {
3172    pub skill: String,
3173    pub level: u32,
3174}
3175
3176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3177pub struct BlueprintView {
3178    pub id: String,
3179    pub label: String,
3180    pub output: String,
3181    pub output_qty: u32,
3182    pub craft_ticks: u32,
3183    pub inputs: Vec<BlueprintIngredientView>,
3184    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
3185    #[serde(default)]
3186    pub station: Option<String>,
3187    #[serde(default)]
3188    pub category: Option<String>,
3189    #[serde(default)]
3190    pub required_tools: Vec<ToolRequirementView>,
3191    #[serde(default)]
3192    pub skill: Option<SkillRequirementView>,
3193    #[serde(default)]
3194    pub failure_chance: f32,
3195    /// Copper cost for the employer to teach this recipe to a hired worker.
3196    #[serde(default)]
3197    pub worker_train_copper: u64,
3198    /// Catalog display name for `output` (UI must prefer this over the raw template id).
3199    #[serde(default)]
3200    pub output_display_name: String,
3201}
3202
3203/// Per-kind pathfinding params replicated so clients share server content speeds.
3204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3205pub struct TerrainKindNavView {
3206    pub kind: TerrainKindView,
3207    #[serde(default = "default_move_speed_mult_one")]
3208    pub move_speed_mult: f32,
3209    #[serde(default)]
3210    pub impassable: bool,
3211}
3212
3213fn default_move_speed_mult_one() -> f32 {
3214    1.0
3215}
3216
3217/// Terrain overlay from segment YAML (`terrain_zones`).
3218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3219#[serde(rename_all = "snake_case")]
3220pub enum TerrainKindView {
3221    #[default]
3222    Grass,
3223    Dirt,
3224    Tilled,
3225    Desert,
3226    Hill,
3227    Bog,
3228    Beach,
3229    ShallowWater,
3230    DeepWater,
3231    Trail,
3232    Road,
3233    Rock,
3234}
3235
3236#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3237pub struct TerrainZoneView {
3238    pub id: String,
3239    pub x0: f32,
3240    pub y0: f32,
3241    pub x1: f32,
3242    pub y1: f32,
3243    #[serde(default)]
3244    pub kind: TerrainKindView,
3245    /// Ground elevation at this zone (m).
3246    #[serde(default)]
3247    pub elevation: f32,
3248    /// Optional map glyph override (single character); falls back to terrain kind catalog.
3249    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
3250    #[serde(default)]
3251    pub glyph: Option<String>,
3252    /// Optional color (`#RRGGBB` or ratatui name); falls back to kind / elevation tint.
3253    #[serde(default)]
3254    pub color: Option<String>,
3255    /// Optional gfx tile id (`terrain.grass`, …) — presentation only.
3256    #[serde(default)]
3257    pub tile_id: Option<String>,
3258    /// Overlap priority — higher wins (`segment.terrain_zones`).
3259    #[serde(default)]
3260    pub z_order: i32,
3261    /// In-progress till/plant channel on this cell (sim ticks).
3262    #[serde(default)]
3263    pub channel_start_tick: Option<Tick>,
3264    #[serde(default)]
3265    pub channel_end_tick: Option<Tick>,
3266}
3267
3268/// Axis-aligned rect used by property / tax zone views.
3269#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3270pub struct ZoneRectView {
3271    pub x0: f32,
3272    pub y0: f32,
3273    pub x1: f32,
3274    pub y1: f32,
3275}
3276
3277/// Crown property zone (claimable land) — plan 40.
3278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3279pub struct PropertyZoneView {
3280    pub id: String,
3281    /// Designer label when present; clients prefer this over id.
3282    #[serde(default)]
3283    pub label: Option<String>,
3284    pub rects: Vec<ZoneRectView>,
3285    #[serde(default)]
3286    pub z_order: i32,
3287    pub crown_price_copper: u64,
3288    pub upkeep_copper_per_day: u64,
3289    #[serde(default)]
3290    pub max_area_m2: Option<f32>,
3291    #[serde(default)]
3292    pub owner_tax_discount_bps: u32,
3293}
3294
3295/// Tax overlay for claim premium preview.
3296#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3297pub struct TaxZoneView {
3298    pub id: String,
3299    #[serde(default)]
3300    pub label: Option<String>,
3301    pub rects: Vec<ZoneRectView>,
3302    #[serde(default)]
3303    pub z_order: i32,
3304    pub rate_bps: u32,
3305    #[serde(default)]
3306    pub flat_copper: u64,
3307    /// Market-hall sales tax in basis points of sale total.
3308    #[serde(default)]
3309    pub market_sales_tax_bps: u32,
3310    /// Optional flat copper per market-hall purchase.
3311    #[serde(default)]
3312    pub market_sales_flat_copper: u32,
3313}
3314
3315/// Town / security / PvP boundary overlay (plan 43).
3316#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3317pub struct BoundaryZoneView {
3318    pub id: String,
3319    #[serde(default)]
3320    pub label: Option<String>,
3321    pub rects: Vec<ZoneRectView>,
3322    #[serde(default)]
3323    pub z_order: i32,
3324    #[serde(default, skip_serializing_if = "Option::is_none")]
3325    pub jurisdiction_id: Option<String>,
3326    #[serde(default = "default_true")]
3327    pub worker_logistics: bool,
3328    #[serde(default)]
3329    pub security_tier: String,
3330    #[serde(default)]
3331    pub pvp_mode: String,
3332    #[serde(default = "default_true")]
3333    pub crime_enabled: bool,
3334    #[serde(default)]
3335    pub guard_response: bool,
3336    /// `fov_los` (default) or `full_aoi`.
3337    #[serde(default)]
3338    pub presence_mode: String,
3339}
3340
3341/// Random encounter overlay (plan 54).
3342#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3343pub struct EncounterZoneView {
3344    pub id: String,
3345    #[serde(default)]
3346    pub label: Option<String>,
3347    pub rects: Vec<ZoneRectView>,
3348    #[serde(default)]
3349    pub z_order: i32,
3350}
3351
3352fn default_true() -> bool {
3353    true
3354}
3355
3356/// Growth / fertility overlay — faster respawn & farm tuning (plan 37 / 40).
3357#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3358pub struct GrowthZoneView {
3359    pub id: String,
3360    #[serde(default)]
3361    pub label: Option<String>,
3362    pub rects: Vec<ZoneRectView>,
3363    #[serde(default)]
3364    pub z_order: i32,
3365    #[serde(default = "default_one_f32")]
3366    pub fertility: f32,
3367}
3368
3369fn default_one_f32() -> f32 {
3370    1.0
3371}
3372
3373/// Climate / biome overlay (plan 38).
3374#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3375pub struct BiomeZoneView {
3376    pub id: String,
3377    #[serde(default)]
3378    pub label: Option<String>,
3379    pub rects: Vec<ZoneRectView>,
3380    #[serde(default)]
3381    pub z_order: i32,
3382    pub biome_id: String,
3383}
3384
3385/// Named tenant on a property plot (farm access + tax discount).
3386#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3387pub struct FarmGrantView {
3388    pub character_id: Uuid,
3389    /// Display name when known (AOI / online); empty if offline-only id.
3390    #[serde(default)]
3391    pub character_label: String,
3392    pub tax_discount_bps: u32,
3393}
3394
3395/// Claimed plot visible to clients (plan 40).
3396#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3397pub struct PropertyPlotView {
3398    pub plot_id: Uuid,
3399    pub property_zone_id: String,
3400    #[serde(default)]
3401    pub zone_label: Option<String>,
3402    pub deed_instance_id: Uuid,
3403    pub x0: f32,
3404    pub y0: f32,
3405    pub x1: f32,
3406    pub y1: f32,
3407    pub upkeep_copper_per_day: u64,
3408    pub arrears_days: u32,
3409    /// Observer currently holds this plot's deed.
3410    #[serde(default)]
3411    pub is_mine: bool,
3412    /// Observer may cultivate/plant/harvest on this plot (deed, owner, public, or grant).
3413    #[serde(default)]
3414    pub may_farm: bool,
3415    /// Book value when known (purchase / last private sale).
3416    #[serde(default)]
3417    pub purchase_basis_copper: u64,
3418    #[serde(default)]
3419    pub farm_public: bool,
3420    #[serde(default)]
3421    pub public_tax_discount_bps: u32,
3422    #[serde(default)]
3423    pub farm_allow: Vec<FarmGrantView>,
3424    /// Owner character when known.
3425    #[serde(default)]
3426    pub owner_character_id: Option<Uuid>,
3427    #[serde(default)]
3428    pub owner_label: Option<String>,
3429    /// Player building on this plot, if any.
3430    #[serde(default)]
3431    pub building_id: Option<String>,
3432    /// Immutable short code derived from `plot_id` (e.g. `xyz1234a`).
3433    #[serde(default)]
3434    pub plot_code: String,
3435    /// Owner-chosen label (defaults to `plot_code`).
3436    #[serde(default)]
3437    pub label: String,
3438}
3439
3440/// Subset of server settings for client-side claim quotes.
3441#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3442pub struct PropertyPlotSettingsView {
3443    pub min_plot_area_m2: f32,
3444    pub tax_premium_weight: f32,
3445    pub sellback_bps: u32,
3446}
3447
3448/// Walkable platform at a fixed z (`segment.z_platforms`).
3449#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3450pub struct ZPlatformView {
3451    pub id: String,
3452    pub z: f32,
3453    pub x0: f32,
3454    pub y0: f32,
3455    pub x1: f32,
3456    pub y1: f32,
3457}
3458
3459/// Stairs / ramp linking two z bands (`segment.z_transitions`).
3460#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3461pub struct ZTransitionView {
3462    pub id: String,
3463    pub z_from: f32,
3464    pub z_to: f32,
3465    pub x0: f32,
3466    pub y0: f32,
3467    pub x1: f32,
3468    pub y1: f32,
3469}
3470
3471#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3472pub struct BuildingView {
3473    pub id: String,
3474    pub label: String,
3475    pub x: f32,
3476    pub y: f32,
3477    pub width_m: f32,
3478    pub depth_m: f32,
3479    #[serde(default)]
3480    pub interior_blueprint: Option<String>,
3481    #[serde(default)]
3482    pub tags: Vec<String>,
3483    /// Boundary zones this market hall participates in (`plans/10`).
3484    #[serde(default)]
3485    pub market_boundary_zone_ids: Vec<String>,
3486    /// Escrow capacity when tagged `market`. None → server default.
3487    #[serde(default)]
3488    pub market_max_volume: Option<f32>,
3489    /// Wall art set id (`schemas/gfx-sprites.schema.json` `wall_set`). `None` → `classic_stone`
3490    /// (legacy `building.wall_h`/`wall_v`/`wall_corner`/`door_open`/`door_closed` sprites).
3491    #[serde(default)]
3492    pub wall_set: Option<String>,
3493    /// Roof art set id (forge `roof_set` output_stem). `None` → `classic_stone`.
3494    #[serde(default)]
3495    pub roof_set: Option<String>,
3496}
3497
3498/// Default wall/roof set id when a building doesn't specify one — keeps existing
3499/// content rendering pixel-identical (`plans/47` Workstream C).
3500pub const DEFAULT_BUILDING_ART_SET: &str = "classic_stone";
3501
3502impl BuildingView {
3503    pub fn effective_wall_set(&self) -> &str {
3504        self.wall_set
3505            .as_deref()
3506            .filter(|s| !s.is_empty())
3507            .unwrap_or(DEFAULT_BUILDING_ART_SET)
3508    }
3509
3510    pub fn effective_roof_set(&self) -> &str {
3511        self.roof_set
3512            .as_deref()
3513            .filter(|s| !s.is_empty())
3514            .unwrap_or(DEFAULT_BUILDING_ART_SET)
3515    }
3516}
3517
3518#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3519pub struct DoorView {
3520    pub id: String,
3521    pub building_id: String,
3522    pub x: f32,
3523    pub y: f32,
3524    #[serde(default)]
3525    pub open: bool,
3526    #[serde(default)]
3527    pub portal: Option<String>,
3528    /// Exterior door locked (player buildings). Locked doors cannot be opened until unlocked.
3529    #[serde(default)]
3530    pub locked: bool,
3531    /// Observer can open/close/enter (`!locked`). Key is only for lock/unlock.
3532    #[serde(default = "default_door_accessible")]
3533    pub accessible: bool,
3534    #[serde(default)]
3535    pub lock_id: Option<Uuid>,
3536}
3537
3538fn default_door_accessible() -> bool {
3539    true
3540}
3541
3542/// Client → server interior room layout (confirm edit).
3543#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3544pub struct InteriorRoomEdit {
3545    pub id: String,
3546    pub label: String,
3547    pub x0: f32,
3548    pub y0: f32,
3549    pub x1: f32,
3550    pub y1: f32,
3551}
3552
3553#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3554pub struct InteriorRoomDoorEdit {
3555    pub id: String,
3556    pub room_a: String,
3557    pub room_b: String,
3558    pub x: f32,
3559    pub y: f32,
3560}
3561
3562#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3563pub struct InteriorRoomView {
3564    pub id: String,
3565    pub label: String,
3566    pub floor: i32,
3567    pub x0: f32,
3568    pub y0: f32,
3569    pub x1: f32,
3570    pub y1: f32,
3571    #[serde(default)]
3572    pub floor_color: Option<String>,
3573    #[serde(default)]
3574    pub floor_glyph: Option<String>,
3575}
3576
3577#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3578pub struct InteriorDoorView {
3579    pub id: String,
3580    pub room_a: String,
3581    pub room_b: String,
3582    pub x: f32,
3583    pub y: f32,
3584    pub kind: String,
3585    #[serde(default)]
3586    pub x_a: Option<f32>,
3587    #[serde(default)]
3588    pub y_a: Option<f32>,
3589    #[serde(default)]
3590    pub x_b: Option<f32>,
3591    #[serde(default)]
3592    pub y_b: Option<f32>,
3593}
3594
3595#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3596pub struct InteriorMapView {
3597    pub building_id: String,
3598    pub blueprint_id: String,
3599    pub background_color: String,
3600    #[serde(default)]
3601    pub default_floor_color: Option<String>,
3602    #[serde(default = "default_floor_height_view")]
3603    pub floor_height_m: f32,
3604    /// Walkable platforms per floor (interior z-bands).
3605    #[serde(default)]
3606    pub z_platforms: Vec<ZPlatformView>,
3607    #[serde(default)]
3608    pub z_transitions: Vec<ZTransitionView>,
3609    pub rooms: Vec<InteriorRoomView>,
3610    #[serde(default)]
3611    pub room_doors: Vec<InteriorDoorView>,
3612}
3613
3614fn default_floor_height_view() -> f32 {
3615    3.0
3616}
3617
3618#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3619pub struct NpcView {
3620    pub id: String,
3621    pub label: String,
3622    pub role: String,
3623    pub x: f32,
3624    pub y: f32,
3625    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
3626    #[serde(default)]
3627    pub building_id: Option<String>,
3628    /// Authoritative sim entity for wildlife / combat targets.
3629    #[serde(default)]
3630    pub entity_id: Option<EntityId>,
3631    #[serde(default)]
3632    pub life_state: Option<LifeState>,
3633    #[serde(default)]
3634    pub hp_pct: Option<f32>,
3635    /// True when this NPC has buy/sell/teach offers (`npc_has_market`).
3636    #[serde(default)]
3637    pub can_trade: bool,
3638    /// Exact item template IDs this NPC buys, for worker-route sell pickers.
3639    #[serde(default)]
3640    pub buy_templates: Vec<String>,
3641    /// Gfx sprite sheet id (`assets/gfx/sprites/`). Client falls back to `npc.{id}` / `npc.{role}`.
3642    #[serde(default)]
3643    pub tile_id: Option<String>,
3644    /// Wildlife FSM state (`idle`, `chase`, `combat`, …) when behavior-driven (debug).
3645    #[serde(default)]
3646    pub behavior_state: Option<String>,
3647    /// Canonical gfx presentation key (`combat`, `pursue`, `walking`, `talking`, …).
3648    #[serde(default)]
3649    pub presentation_state: Option<String>,
3650    /// Resolved gfx sprite mode for `tile_id` (server-computed).
3651    #[serde(default)]
3652    pub sprite_mode: Option<String>,
3653    /// Paperdoll skin id (`assets/paperdoll/skins/`). Client prefers this over `tile_id` when baked.
3654    #[serde(default)]
3655    pub paperdoll_ref: Option<String>,
3656    /// World draw size in map cells (from paperdoll skin `draw_scale`; 1.0 = one cell).
3657    #[serde(default = "default_draw_scale")]
3658    pub draw_scale: f32,
3659    /// Authoritative body yaw (radians) for wildlife LoS debug (F7).
3660    #[serde(default)]
3661    pub yaw: Option<f32>,
3662    /// Active visual acquire cone (degrees); 360 when engaged.
3663    #[serde(default)]
3664    pub perception_fov_deg: Option<f32>,
3665    /// Sight radius used for acquire/tracking (meters).
3666    #[serde(default)]
3667    pub perception_sight_m: Option<f32>,
3668    /// Hearing radius from behavior (meters); omitted when deaf.
3669    #[serde(default)]
3670    pub perception_hear_m: Option<f32>,
3671}
3672
3673#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3674pub struct UseResult {
3675    pub template_id: String,
3676    pub hunger_restored: f32,
3677    pub thirst_restored: f32,
3678    #[serde(default)]
3679    pub health_restored: f32,
3680    #[serde(default)]
3681    pub mana_restored: f32,
3682    #[serde(default)]
3683    pub cleared_dot_ids: Vec<String>,
3684}
3685
3686#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3687pub struct CraftResult {
3688    pub blueprint_id: String,
3689    pub outputs: Vec<ItemStack>,
3690    pub consumed: Vec<ItemStack>,
3691    /// 1-based index within the submitted batch (1 when not batching).
3692    #[serde(default = "default_one")]
3693    pub batch_index: u32,
3694    /// Total crafts requested in this batch (1 when not batching).
3695    #[serde(default = "default_one")]
3696    pub batch_total: u32,
3697}
3698
3699fn default_one() -> u32 {
3700    1
3701}
3702
3703#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3704pub struct DeathNotice {
3705    pub entity_id: EntityId,
3706    pub respawn_x: f32,
3707    pub respawn_y: f32,
3708    pub message: String,
3709}
3710
3711#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3712pub struct InteractionNotice {
3713    pub target_id: String,
3714    pub message: String,
3715    #[serde(default)]
3716    pub coins_delta: i32,
3717    #[serde(default)]
3718    pub inventory_delta: Vec<ItemStack>,
3719}
3720
3721#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3722#[serde(rename_all = "snake_case")]
3723pub enum NpcTalkTrustFlag {
3724    Stranger,
3725    Acquainted,
3726    Trusted,
3727}
3728
3729#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
3730#[serde(rename_all = "snake_case")]
3731pub enum NpcTalkDepth {
3732    #[default]
3733    Full,
3734    Brief,
3735    Unavailable,
3736}
3737
3738#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3739pub struct NpcTalkOpened {
3740    pub npc_id: String,
3741    pub npc_label: String,
3742    pub greeting: String,
3743    pub trust_flag: NpcTalkTrustFlag,
3744    #[serde(default)]
3745    pub talk_depth: NpcTalkDepth,
3746    #[serde(default = "default_true")]
3747    pub trade_allowed: bool,
3748    /// Keyword/topic hints for deterministic chat (e.g. "Trade", "Goblins").
3749    #[serde(default)]
3750    pub suggested_topics: Vec<String>,
3751}
3752
3753#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3754pub struct NpcTalkPending {
3755    pub npc_id: String,
3756}
3757
3758#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3759pub struct NpcTalkReply {
3760    pub npc_id: String,
3761    pub line: String,
3762    pub trust_flag: NpcTalkTrustFlag,
3763    #[serde(default)]
3764    pub wind_down: bool,
3765    #[serde(default)]
3766    pub trade_disabled: bool,
3767}
3768
3769#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3770pub struct NpcTalkClosed {
3771    pub npc_id: String,
3772}
3773
3774#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3775pub struct NpcTalkError {
3776    pub npc_id: String,
3777    pub reason: String,
3778}
3779
3780#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3781#[serde(rename_all = "snake_case")]
3782pub enum QuestStatusView {
3783    Available,
3784    Active,
3785    Completed,
3786}
3787
3788#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3789pub struct QuestObjectiveProgress {
3790    pub label: String,
3791    pub current: u32,
3792    pub required: u32,
3793    pub done: bool,
3794    /// `talk_npc` / `kill_npc` / `harvest_item` / `give_item` / `interact` /
3795    /// `craft` / `buy_item` / `sell_item` / `enter_building` / `use_item` /
3796    /// `pickup_item` / `learn_blueprint`.
3797    #[serde(default)]
3798    pub kind: String,
3799    #[serde(default)]
3800    pub npc_ref: Option<String>,
3801    #[serde(default)]
3802    pub item_template: Option<String>,
3803    #[serde(default)]
3804    pub blueprint_id: Option<String>,
3805    #[serde(default)]
3806    pub building_id: Option<String>,
3807}
3808
3809#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3810pub struct QuestLogEntry {
3811    pub quest_id: String,
3812    pub title: String,
3813    pub description: String,
3814    pub status: QuestStatusView,
3815    #[serde(default)]
3816    pub current_step_id: Option<String>,
3817    #[serde(default)]
3818    pub current_step_title: String,
3819    #[serde(default)]
3820    pub objectives: Vec<QuestObjectiveProgress>,
3821    #[serde(default)]
3822    pub is_tracked: bool,
3823    #[serde(default)]
3824    pub can_withdraw: bool,
3825}
3826
3827#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3828pub struct InteractableView {
3829    pub id: String,
3830    pub kind: String,
3831    pub label: String,
3832    pub x: f32,
3833    pub y: f32,
3834    pub z: f32,
3835    #[serde(default)]
3836    pub board_id: Option<String>,
3837}
3838
3839#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3840pub struct QuestOffer {
3841    pub quest_id: String,
3842    pub title: String,
3843    pub description: String,
3844    #[serde(default)]
3845    pub step_count: u32,
3846}
3847
3848#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3849pub struct QuestCatalogEntry {
3850    pub quest_id: String,
3851    pub title: String,
3852    pub description: String,
3853    pub step_count: u32,
3854    #[serde(default)]
3855    pub board_ids: Vec<String>,
3856}
3857
3858#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3859pub struct QuestCatalogUpdated {
3860    pub revision: u64,
3861    pub game_day: String,
3862    #[serde(default)]
3863    pub accepted: Vec<QuestCatalogEntry>,
3864    #[serde(default)]
3865    pub retired: Vec<String>,
3866}
3867
3868#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3869pub struct QuestNotice {
3870    pub quest_id: String,
3871    pub title: String,
3872    pub message: String,
3873}
3874
3875#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3876#[serde(rename_all = "snake_case")]
3877pub enum ShopOfferKind {
3878    Item,
3879    Blueprint,
3880}
3881
3882#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3883pub struct ShopOffer {
3884    pub offer_id: String,
3885    pub kind: ShopOfferKind,
3886    pub label: String,
3887    #[serde(default)]
3888    pub template_id: Option<String>,
3889    #[serde(default)]
3890    pub blueprint_id: Option<String>,
3891    pub price_copper: u32,
3892    #[serde(default)]
3893    pub affordable: bool,
3894    #[serde(default)]
3895    pub already_owned: bool,
3896}
3897
3898#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3899pub struct ShopBuyLine {
3900    pub template_id: String,
3901    pub label: String,
3902    pub quantity: u32,
3903    pub price_copper: u32,
3904}
3905
3906/// Bank teller interaction panel (`plans/08` §8).
3907#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3908pub struct BankPanel {
3909    pub npc_id: String,
3910    pub npc_label: String,
3911    pub bank_balance_copper: u64,
3912    pub on_person_copper: u64,
3913    /// Copper still clearing to other accounts (debited, not yet credited).
3914    #[serde(default)]
3915    pub pending_outgoing_copper: u64,
3916    #[serde(default)]
3917    pub transfer_fee_bps: u32,
3918    #[serde(default)]
3919    pub transfer_clear_ticks: u64,
3920}
3921
3922/// Town storage manager panel (`plans/08` §7).
3923#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3924pub struct StoragePanel {
3925    pub npc_id: String,
3926    pub npc_label: String,
3927    pub building_id: String,
3928    pub building_label: String,
3929    pub used_volume: f32,
3930    pub max_volume: f32,
3931    #[serde(default)]
3932    pub contents: Vec<ItemStack>,
3933    /// Other storage buildings that can receive a ship (id, label, distance_m, fee_copper, travel_ticks).
3934    #[serde(default)]
3935    pub ship_destinations: Vec<StorageShipDest>,
3936}
3937
3938#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3939pub struct StorageShipDest {
3940    pub building_id: String,
3941    pub label: String,
3942    pub distance_m: f32,
3943    pub fee_copper: u64,
3944    pub travel_ticks: u64,
3945}
3946
3947/// Source or destination for market-hall goods movement
3948/// (`plans/10-economy-and-markets.md` §4.3/§4.4).
3949#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3950pub enum GoodsLocation {
3951    /// On-person inventory (root, non-nested).
3952    Person,
3953    /// A town storage vault at a building whose jurisdiction intersects the
3954    /// listing hall's `market_boundary_zone_ids`.
3955    TownStorage { building_id: String },
3956}
3957
3958/// One escrowed market-hall listing, as seen from a browsing hall
3959/// (`plans/10-economy-and-markets.md` §4.2).
3960#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3961pub struct MarketListingView {
3962    pub listing_id: Uuid,
3963    pub seller_character_id: Uuid,
3964    /// Seller display name (`prefer-labels-over-ids`).
3965    pub seller_label: String,
3966    pub hall_building_id: String,
3967    pub hall_label: String,
3968    pub template_id: String,
3969    pub display_name: String,
3970    /// Item catalog category (`resource`, `weapon`, …) for client filters.
3971    #[serde(default)]
3972    pub category: String,
3973    pub quantity: u32,
3974    pub unit_price_copper: u64,
3975    /// Total for the full remaining quantity (`quantity * unit_price_copper`).
3976    pub line_total_copper: u64,
3977    /// Dump-queue listing — players cannot buy (`plans/53`).
3978    #[serde(default)]
3979    pub npc_price: bool,
3980    /// Estimated net copper per unit for NPC-price rows (default buyer rates).
3981    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
3982    #[serde(default)]
3983    pub npc_dump_unit_copper: Option<u32>,
3984    /// True when the viewer is the seller — reprice/delist allowed.
3985    pub mine: bool,
3986}
3987
3988/// Town storage vault eligible as a list/buy/delist goods source for a market hall.
3989#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3990pub struct MarketListVault {
3991    pub building_id: String,
3992    /// Designer label (`prefer-labels-over-ids`).
3993    pub building_label: String,
3994    #[serde(default)]
3995    pub contents: Vec<ItemStack>,
3996}
3997
3998/// Market hall clerk panel — browse (zone-linked halls), list / reprice / delist,
3999/// buy confirm (`plans/10-economy-and-markets.md` §4, §10).
4000#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4001pub struct MarketPanel {
4002    pub npc_id: String,
4003    pub npc_label: String,
4004    pub building_id: String,
4005    pub building_label: String,
4006    /// Escrow volume used at *this* hall only (cap is per-hall, `market_max_volume`).
4007    pub used_volume: f32,
4008    pub max_volume: f32,
4009    /// Listings at this hall plus any zone-linked halls (`market_boundary_zone_ids`
4010    /// intersection) — the shared browse book (§4.2).
4011    #[serde(default)]
4012    pub listings: Vec<MarketListingView>,
4013    /// Crown sales tax at the tax zone covering this hall (§6).
4014    #[serde(default)]
4015    pub tax_bps: u32,
4016    #[serde(default)]
4017    pub tax_flat_copper: u32,
4018    /// Zone-eligible town storage vaults the caller can list from (§4.3).
4019    #[serde(default)]
4020    pub list_vaults: Vec<MarketListVault>,
4021}
4022
4023#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4024pub struct ShopCatalog {
4025    pub npc_id: String,
4026    pub npc_label: String,
4027    #[serde(default)]
4028    pub sells: Vec<ShopOffer>,
4029    #[serde(default)]
4030    pub buys: Vec<ShopBuyLine>,
4031}
4032
4033#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4034pub struct HarvestResult {
4035    pub node_id: String,
4036    /// Stack quantity granted (not one-node-one-instance).
4037    pub quantity: u32,
4038    pub item_template: String,
4039    /// Optional DB row id when persisted to control plane.
4040    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
4041    #[serde(default)]
4042    pub item_instance_id: Option<Uuid>,
4043}
4044
4045/// Every on-wire payload is wrapped for versioning and codec uniformity.
4046#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4047pub struct Envelope<T> {
4048    pub protocol_version: u16,
4049    pub payload: T,
4050}
4051
4052impl<T> Envelope<T> {
4053    pub fn new(payload: T) -> Self {
4054        Self {
4055            protocol_version: crate::PROTOCOL_VERSION,
4056            payload,
4057        }
4058    }
4059}
4060
4061/// Session handshake after transport connect.
4062#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4063pub struct Hello {
4064    pub client_name: String,
4065    pub protocol_version: u16,
4066    #[serde(default)]
4067    pub auth: AuthCredential,
4068    /// Required for session auth; embedded in `ApiToken` variant otherwise.
4069    #[serde(default)]
4070    pub character_id: Option<Uuid>,
4071}
4072
4073/// How the client authenticates to the game gateway.
4074/// Uses default serde enum encoding (postcard-compatible; not internally tagged).
4075#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4076#[serde(rename_all = "snake_case")]
4077pub enum AuthCredential {
4078    DevLocal,
4079    Session { token: String },
4080    ApiToken { token: String, character_id: Uuid },
4081}
4082
4083impl Default for AuthCredential {
4084    fn default() -> Self {
4085        Self::DevLocal
4086    }
4087}
4088
4089#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4090pub struct Welcome {
4091    pub session_id: SessionId,
4092    pub entity_id: EntityId,
4093    pub snapshot: Snapshot,
4094}
4095
4096#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4097pub enum ServerMessage {
4098    Welcome(Welcome),
4099    /// Static world layers refreshed (blueprints, catalog, map, terrain) — no client restart.
4100    ContentUpdated(Snapshot),
4101    Tick(TickDelta),
4102    IntentAck {
4103        entity_id: EntityId,
4104        seq: Seq,
4105        tick: Tick,
4106    },
4107    Chat(ChatMessage),
4108    HarvestResult(HarvestResult),
4109    UseResult(UseResult),
4110    CraftResult(CraftResult),
4111    Death(DeathNotice),
4112    Interaction(InteractionNotice),
4113    ShopOpened(ShopCatalog),
4114    NpcTalkOpened(NpcTalkOpened),
4115    NpcTalkPending(NpcTalkPending),
4116    NpcTalkReply(NpcTalkReply),
4117    NpcTalkClosed(NpcTalkClosed),
4118    NpcTalkError(NpcTalkError),
4119    QuestOffer(QuestOffer),
4120    QuestAccepted(QuestNotice),
4121    QuestWithdrawn(QuestNotice),
4122    QuestStepCompleted(QuestNotice),
4123    QuestCompleted(QuestNotice),
4124    QuestCatalogUpdated(QuestCatalogUpdated),
4125    /// Bank teller panel opened (`plans/08` §8).
4126    BankOpened(BankPanel),
4127    /// Town storage manager panel opened (`plans/08` §7).
4128    StorageOpened(StoragePanel),
4129    /// Market hall clerk panel opened or refreshed (`plans/10-economy-and-markets.md`).
4130    MarketOpened(MarketPanel),
4131    /// Player-to-player trade window opened or refreshed.
4132    TradeOpened(TradePanel),
4133    /// Trade ended (cancel, complete, or peer left range).
4134    TradeClosed {
4135        reason: String,
4136    },
4137    /// Hello accepted but play refused (character already online, etc.).
4138    /// Sent instead of Welcome; TCP closes afterward.
4139    ConnectRejected {
4140        reason: String,
4141    },
4142}
4143
4144/// Live player-to-player trade escrow view (both sides see the same presented buckets).
4145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4146pub struct TradePanel {
4147    pub peer_entity_id: EntityId,
4148    pub peer_name: String,
4149    pub my_presented: Vec<ItemStack>,
4150    pub their_presented: Vec<ItemStack>,
4151    pub i_ready: bool,
4152    pub they_ready: bool,
4153    /// Predicted carry mass after accepting their presented items (and losing mine).
4154    pub my_mass_after: f32,
4155    pub my_mass_max: f32,
4156    pub my_encumbrance_after: EncumbranceState,
4157    /// True when completing would put the local player Over.
4158    pub overburden_warning: bool,
4159}
4160
4161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4162pub enum ClientMessage {
4163    Hello(Hello),
4164    Intent(Intent),
4165    Disconnect,
4166}
4167
4168#[cfg(test)]
4169mod tests {
4170    use super::*;
4171
4172    #[test]
4173    fn pristine_vitals_state_yields_full_pools() {
4174        let attrs = PrimaryAttributes::default();
4175        let vitals = StoredVitalsState::default().apply_to(attrs);
4176        assert!(vitals.health > 0.0);
4177        assert_eq!(vitals.health, vitals.health_max);
4178        assert!((vitals.mana_max - 61.0).abs() < 0.01);
4179    }
4180
4181    #[test]
4182    fn humanize_snake_id_title_cases_parts() {
4183        assert_eq!(humanize_snake_id("heal_touch"), "Heal Touch");
4184        assert_eq!(humanize_snake_id("fireball"), "Fireball");
4185        assert_eq!(humanize_snake_id("cone_frost"), "Cone Frost");
4186    }
4187
4188    #[test]
4189    fn saved_vitals_scale_when_pool_max_increases() {
4190        let mut attrs = PrimaryAttributes::default();
4191        attrs.intelligence = 140;
4192        attrs.wisdom = 140;
4193        let saved = StoredVitalsState {
4194            health: 100.0,
4195            mana: 14.0,
4196            stamina: 100.0,
4197            ..StoredVitalsState::default()
4198        };
4199        let vitals = saved.apply_to(attrs);
4200        assert!(vitals.mana_max > 55.0);
4201        assert!(
4202            (vitals.mana - vitals.mana_max).abs() < 0.01,
4203            "full legacy mana bar migrates to full new bar"
4204        );
4205    }
4206
4207    #[test]
4208    fn empty_vitals_state_is_pristine() {
4209        let pristine = StoredVitalsState {
4210            health: 0.0,
4211            mana: 0.0,
4212            stamina: 0.0,
4213            hunger: 0.0,
4214            thirst: 0.0,
4215            coins: 0,
4216            deaths: 0,
4217            life_state: LifeState::Alive,
4218        };
4219        assert!(pristine.is_pristine());
4220        let vitals = pristine.apply_to(PrimaryAttributes::default());
4221        assert!(vitals.health > 0.0);
4222    }
4223
4224    #[test]
4225    fn stored_vitals_roundtrip_preserves_partial_pools() {
4226        let attrs = PrimaryAttributes::default();
4227        let mut live = PlayerVitals::from_attributes(attrs);
4228        live.health = 25.0;
4229        live.hunger = 77.0;
4230        live.deaths = 2;
4231        let stored = StoredVitalsState::from_live(&live);
4232        let restored = stored.apply_to(attrs);
4233        assert!(
4234            (restored.health - 25.0).abs() < 0.01,
4235            "partial HP below cap stays absolute"
4236        );
4237        assert_eq!(restored.hunger, 77.0);
4238        assert_eq!(restored.deaths, 2);
4239    }
4240
4241    #[test]
4242    fn skill_tiers_start_at_zero() {
4243        let skill = SkillProgress::default();
4244        assert_eq!(skill.level, 0);
4245        assert_eq!(skill.display_tier(), 0);
4246        let trained = SkillProgress {
4247            level: 250,
4248            last_trained_tick: 1,
4249        };
4250        assert_eq!(trained.display_tier(), 2);
4251    }
4252
4253    #[test]
4254    fn quest_server_messages_roundtrip_json() {
4255        use crate::codec::{Codec, PostcardCodec};
4256
4257        let offer = ServerMessage::QuestOffer(QuestOffer {
4258            quest_id: "ada_goblin_hunt".into(),
4259            title: "Goblin Trouble".into(),
4260            description: "Help Ada".into(),
4261            step_count: 3,
4262        });
4263        let notice = ServerMessage::QuestAccepted(QuestNotice {
4264            quest_id: "ada_goblin_hunt".into(),
4265            title: "Goblin Trouble".into(),
4266            message: "Quest accepted".into(),
4267        });
4268        for msg in [offer, notice] {
4269            let bytes = PostcardCodec.encode(&msg).unwrap();
4270            let decoded: ServerMessage = PostcardCodec.decode(&bytes).unwrap();
4271            assert_eq!(decoded, msg);
4272        }
4273    }
4274
4275    #[test]
4276    fn hotbar_consumable_binding_roundtrips() {
4277        let binding = hotbar_consumable_binding("bottle_of_water");
4278        assert_eq!(binding, "item:bottle_of_water");
4279        assert!(hotbar_binding_is_consumable(&binding));
4280        assert_eq!(
4281            hotbar_consumable_template(&binding),
4282            Some("bottle_of_water")
4283        );
4284        assert!(!hotbar_binding_is_consumable("fireball"));
4285        assert_eq!(hotbar_consumable_template("fireball"), None);
4286    }
4287}