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; re-press during window refreshes (`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        /// When set, deliver that quest's offer or `talk_npc` line. Casual Talk omits this.
1167        #[serde(default)]
1168        quest_id: Option<String>,
1169        seq: Seq,
1170    },
1171    /// Send a player message in an open NPC conversation.
1172    NpcTalkSay {
1173        entity_id: EntityId,
1174        npc_id: String,
1175        message: String,
1176        seq: Seq,
1177    },
1178    /// Close an NPC conversation.
1179    NpcTalkClose {
1180        entity_id: EntityId,
1181        npc_id: String,
1182        seq: Seq,
1183    },
1184    /// Accept a discovered quest (adds to active list).
1185    AcceptQuest {
1186        entity_id: EntityId,
1187        quest_id: String,
1188        seq: Seq,
1189    },
1190    /// Withdraw from an active quest (resets progress; can re-accept).
1191    WithdrawQuest {
1192        entity_id: EntityId,
1193        quest_id: String,
1194        seq: Seq,
1195    },
1196    /// Highlight an active quest in the HUD.
1197    TrackQuest {
1198        entity_id: EntityId,
1199        quest_id: String,
1200        seq: Seq,
1201    },
1202    /// Turn in items for an active give_item objective while near an NPC.
1203    QuestGiveItem {
1204        entity_id: EntityId,
1205        npc_id: String,
1206        template_id: String,
1207        #[serde(default = "default_one")]
1208        quantity: u32,
1209        seq: Seq,
1210    },
1211    /// Hire an NPC worker (fee + recurring wage).
1212    HireWorker {
1213        entity_id: EntityId,
1214        def_id: String,
1215        wage_copper_per_interval: u32,
1216        #[serde(default)]
1217        lodging_container_id: Option<String>,
1218        #[serde(default)]
1219        job_yaml: Option<String>,
1220        seq: Seq,
1221    },
1222    /// Release a hired worker instance.
1223    DismissWorker {
1224        entity_id: EntityId,
1225        worker_instance_id: String,
1226        seq: Seq,
1227    },
1228    /// Replace or assign a worker job YAML loop.
1229    SetWorkerJob {
1230        entity_id: EntityId,
1231        worker_instance_id: String,
1232        job_yaml: String,
1233        seq: Seq,
1234    },
1235    /// Point a worker at a camp bed / lodging container.
1236    AssignWorkerLodging {
1237        entity_id: EntityId,
1238        worker_instance_id: String,
1239        lodging_container_id: String,
1240        seq: Seq,
1241    },
1242    /// Switch companion vs job-loop automation mode.
1243    SetWorkerMode {
1244        entity_id: EntityId,
1245        worker_instance_id: String,
1246        mode: String,
1247        seq: Seq,
1248    },
1249    /// Equip one item from the employer's inventory on a hired worker.
1250    ///
1251    /// `slot` accepts `mainhand`, `offhand`, or a `BodySlot` name such as
1252    /// `chest` / `head`. The item remains owned by the worker while equipped
1253    /// and is included in worker snapshots.
1254    EquipWorkerItem {
1255        entity_id: EntityId,
1256        worker_instance_id: String,
1257        item_instance_id: uuid::Uuid,
1258        slot: String,
1259        seq: Seq,
1260    },
1261    /// Hand an item from the player's inventory to a hired worker (e.g. a tool the
1262    /// worker must carry but not consume, like a handsaw for `oak_to_lumber`).
1263    GiveWorkerItem {
1264        entity_id: EntityId,
1265        worker_instance_id: String,
1266        item_instance_id: uuid::Uuid,
1267        #[serde(default)]
1268        quantity: Option<u32>,
1269        seq: Seq,
1270    },
1271    /// Take an item from a hired worker's inventory back into the employer's root.
1272    TakeWorkerItem {
1273        entity_id: EntityId,
1274        worker_instance_id: String,
1275        item_instance_id: uuid::Uuid,
1276        #[serde(default)]
1277        quantity: Option<u32>,
1278        seq: Seq,
1279    },
1280    /// Set a custom display name for a hired worker (shown in menus / route editor).
1281    RenameHiredWorker {
1282        entity_id: EntityId,
1283        worker_instance_id: String,
1284        name: String,
1285        seq: Seq,
1286    },
1287    /// Rename an owned property plot label (deed title and Location HUD follow).
1288    RenamePropertyPlot {
1289        entity_id: EntityId,
1290        plot_id: Uuid,
1291        label: String,
1292        seq: Seq,
1293    },
1294    /// Teach a known blueprint to a hired worker (costs `worker_train_copper`).
1295    TeachWorkerBlueprint {
1296        entity_id: EntityId,
1297        worker_instance_id: String,
1298        blueprint_id: String,
1299        seq: Seq,
1300    },
1301    /// Employer opened/closed the worker manage UI (`f` next to them) — pause job steps.
1302    AttendHiredWorker {
1303        entity_id: EntityId,
1304        worker_instance_id: String,
1305        attending: bool,
1306        seq: Seq,
1307    },
1308    /// Buy a fractional plot inside a crown property zone (plan 40). Mints a deed.
1309    BuyPlot {
1310        entity_id: EntityId,
1311        zone_id: String,
1312        x0: f32,
1313        y0: f32,
1314        x1: f32,
1315        y1: f32,
1316        seq: Seq,
1317    },
1318    /// Claim the largest free AABB in a property zone (plan 40).
1319    BuyPlotAllFree {
1320        entity_id: EntityId,
1321        zone_id: String,
1322        seq: Seq,
1323    },
1324    /// Sell a plot back to the crown (requires holding the deed).
1325    SellPlotToCrown {
1326        entity_id: EntityId,
1327        plot_id: Uuid,
1328        seq: Seq,
1329    },
1330    /// Cultivate one cell under/near the player into tilled soil (plan 40 P1).
1331    Cultivate {
1332        entity_id: EntityId,
1333        /// World cell to till (floor coords). Must be on an owned plot.
1334        x: f32,
1335        y: f32,
1336        seq: Seq,
1337    },
1338    /// Plant seeds onto free tilled cells on owned plots near the player (plan 40 P2).
1339    PlantSeeds {
1340        entity_id: EntityId,
1341        seed_template_id: String,
1342        quantity: u32,
1343        seq: Seq,
1344    },
1345    /// Toggle public farm access + public tax discount on an owned plot.
1346    SetPlotFarmPublic {
1347        entity_id: EntityId,
1348        plot_id: Uuid,
1349        public: bool,
1350        #[serde(default)]
1351        public_tax_discount_bps: u32,
1352        seq: Seq,
1353    },
1354    /// Add or update a named tenant farm grant (negotiated tax discount).
1355    PlotFarmAllowUpsert {
1356        entity_id: EntityId,
1357        plot_id: Uuid,
1358        /// Preferred when known.
1359        #[serde(default)]
1360        character_id: Option<Uuid>,
1361        /// Fallback: match online player display name (case-insensitive).
1362        #[serde(default)]
1363        character_name: String,
1364        #[serde(default)]
1365        tax_discount_bps: u32,
1366        seq: Seq,
1367    },
1368    /// Remove a named tenant from a plot's farm allow-list.
1369    PlotFarmAllowRemove {
1370        entity_id: EntityId,
1371        plot_id: Uuid,
1372        character_id: Uuid,
1373        seq: Seq,
1374    },
1375    /// Start timed craft to build a shell on the plot's solid tilled rectangle (plan 20 §18).
1376    /// Materials are taken from town storage (plot in boundary) or a nearby chest (outside).
1377    StartPlotBuild {
1378        entity_id: EntityId,
1379        plot_id: Uuid,
1380        wall_material_id: String,
1381        roof_material_id: String,
1382        seq: Seq,
1383    },
1384    CancelPlotBuild {
1385        entity_id: EntityId,
1386        seq: Seq,
1387    },
1388    /// Lock/unlock a player building exterior door (requires matching key).
1389    /// Door must be closed to lock.
1390    SetDoorLocked {
1391        entity_id: EntityId,
1392        door_id: String,
1393        locked: bool,
1394        seq: Seq,
1395    },
1396    /// Walk in through an already-open player-building exterior door.
1397    /// Latch open/close stays on [`Intent::Interact`] (`f`); map buildings still use open→enter.
1398    EnterBuildingDoor {
1399        entity_id: EntityId,
1400        door_id: String,
1401        seq: Seq,
1402    },
1403    /// Leave through an exterior portal from inside a player building.
1404    /// Always allowed even if the door is closed or locked (anti-trap).
1405    ExitBuildingDoor {
1406        entity_id: EntityId,
1407        door_id: String,
1408        seq: Seq,
1409    },
1410    /// Apply interior room layout; copper charged once on confirm.
1411    ConfirmInteriorEdit {
1412        entity_id: EntityId,
1413        building_id: String,
1414        rooms: Vec<InteriorRoomEdit>,
1415        room_doors: Vec<InteriorRoomDoorEdit>,
1416        seq: Seq,
1417    },
1418    CancelInteriorEdit {
1419        entity_id: EntityId,
1420        building_id: String,
1421        seq: Seq,
1422    },
1423    /// Deposit physical coins into the bank ledger at a teller (`plans/08` §8).
1424    BankDeposit {
1425        entity_id: EntityId,
1426        npc_id: String,
1427        /// Copper to deposit; `0` means deposit all on-person copper.
1428        #[serde(default)]
1429        amount_copper: u64,
1430        seq: Seq,
1431    },
1432    /// Withdraw copper from the bank ledger as physical coins at a teller.
1433    BankWithdraw {
1434        entity_id: EntityId,
1435        npc_id: String,
1436        /// Copper to withdraw; `0` means withdraw all bank balance.
1437        #[serde(default)]
1438        amount_copper: u64,
1439        seq: Seq,
1440    },
1441    /// Close the bank teller UI.
1442    BankClose {
1443        entity_id: EntityId,
1444        npc_id: String,
1445        seq: Seq,
1446    },
1447    /// Magical clearinghouse transfer to another character's bank ledger (`plans/08` §8.3).
1448    BankTransfer {
1449        entity_id: EntityId,
1450        npc_id: String,
1451        /// Recipient character id (preferred when known).
1452        #[serde(default)]
1453        to_character_id: Option<Uuid>,
1454        /// Fallback: match an online player's display name (case-insensitive).
1455        #[serde(default)]
1456        to_name: String,
1457        /// Copper to send (fee is extra, taken from sender bank balance).
1458        amount_copper: u64,
1459        seq: Seq,
1460    },
1461    /// Store an on-person item into the town storage vault at a storage manager.
1462    StorageStore {
1463        entity_id: EntityId,
1464        npc_id: String,
1465        item_instance_id: Uuid,
1466        #[serde(default)]
1467        quantity: Option<u32>,
1468        seq: Seq,
1469    },
1470    /// Take an item from the town storage vault onto person.
1471    StorageTake {
1472        entity_id: EntityId,
1473        npc_id: String,
1474        item_instance_id: Uuid,
1475        #[serde(default)]
1476        quantity: Option<u32>,
1477        seq: Seq,
1478    },
1479    /// Ship vault items to another storage building (distance fee + travel time).
1480    StorageShip {
1481        entity_id: EntityId,
1482        npc_id: String,
1483        dest_building_id: String,
1484        item_instance_id: Uuid,
1485        #[serde(default)]
1486        quantity: Option<u32>,
1487        seq: Seq,
1488    },
1489    /// Close the storage manager UI.
1490    StorageClose {
1491        entity_id: EntityId,
1492        npc_id: String,
1493        seq: Seq,
1494    },
1495    /// List goods from person or town storage into a market hall's escrow
1496    /// (`plans/10-economy-and-markets.md` §4.3).
1497    MarketList {
1498        entity_id: EntityId,
1499        npc_id: String,
1500        source: GoodsLocation,
1501        item_instance_id: Uuid,
1502        #[serde(default)]
1503        quantity: Option<u32>,
1504        unit_price_copper: u64,
1505        /// When true, `unit_price_copper` is ignored — dump-queue mode (`plans/53`).
1506        #[serde(default)]
1507        npc_price: bool,
1508        seq: Seq,
1509    },
1510    /// Change the unit price of one of the caller's own listings.
1511    MarketReprice {
1512        entity_id: EntityId,
1513        npc_id: String,
1514        listing_id: Uuid,
1515        unit_price_copper: u64,
1516        seq: Seq,
1517    },
1518    /// Pull one of the caller's own listings back out of escrow.
1519    MarketDelist {
1520        entity_id: EntityId,
1521        npc_id: String,
1522        listing_id: Uuid,
1523        dest: GoodsLocation,
1524        seq: Seq,
1525    },
1526    /// Buy some or all of a listing's remaining quantity (§4.4).
1527    MarketBuy {
1528        entity_id: EntityId,
1529        npc_id: String,
1530        listing_id: Uuid,
1531        #[serde(default = "default_one")]
1532        quantity: u32,
1533        dest: GoodsLocation,
1534        seq: Seq,
1535    },
1536    /// Close the market clerk UI.
1537    MarketClose {
1538        entity_id: EntityId,
1539        npc_id: String,
1540        seq: Seq,
1541    },
1542    /// Request a player-to-player trade with a nearby peer.
1543    TradeRequest {
1544        entity_id: EntityId,
1545        peer_entity_id: EntityId,
1546        seq: Seq,
1547    },
1548    /// Accept or decline a pending trade request.
1549    TradeRespond {
1550        entity_id: EntityId,
1551        peer_entity_id: EntityId,
1552        accept: bool,
1553        seq: Seq,
1554    },
1555    /// Move an inventory item into the trade escrow (presented bucket).
1556    TradePresent {
1557        entity_id: EntityId,
1558        item_instance_id: Uuid,
1559        #[serde(default)]
1560        quantity: Option<u32>,
1561        seq: Seq,
1562    },
1563    /// Return a presented item from escrow to inventory.
1564    TradeUnpresent {
1565        entity_id: EntityId,
1566        item_instance_id: Uuid,
1567        seq: Seq,
1568    },
1569    /// Toggle ready / handshake on the open trade.
1570    TradeSetReady {
1571        entity_id: EntityId,
1572        ready: bool,
1573        seq: Seq,
1574    },
1575    /// Cancel the open trade (or withdraw a pending request).
1576    TradeCancel {
1577        entity_id: EntityId,
1578        seq: Seq,
1579    },
1580    /// Destroy a paired whisper stone from the whisper pouch (cut contact).
1581    DestroyWhisperStone {
1582        entity_id: EntityId,
1583        item_instance_id: Uuid,
1584        seq: Seq,
1585    },
1586    /// Stow a blank or paired whisper stone from inventory into the pouch.
1587    StowWhisperStone {
1588        entity_id: EntityId,
1589        item_instance_id: Uuid,
1590        seq: Seq,
1591    },
1592    /// Send a companion worker on a one-shot run to deposit all carried items
1593    /// into the nearest employer-owned storage in the worker's jurisdiction.
1594    DeliverWorkerToNearestStorage {
1595        entity_id: EntityId,
1596        worker_instance_id: String,
1597        seq: Seq,
1598    },
1599    /// Cancel an active companion delivery and leave the worker where they are.
1600    CancelWorkerDelivery {
1601        entity_id: EntityId,
1602        worker_instance_id: String,
1603        seq: Seq,
1604    },
1605}
1606
1607fn default_block_enabled() -> bool {
1608    true
1609}
1610
1611/// One active status effect for HUD (buff/debuff icon strip).
1612#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1613pub struct StatusEffectHud {
1614    pub effect_id: String,
1615    pub label: String,
1616    #[serde(default)]
1617    pub polarity: String,
1618    #[serde(default)]
1619    pub icon_tile_id: Option<String>,
1620    /// Designer-authored dot color (`#RRGGBB`); gfx falls back to polarity tint when absent.
1621    #[serde(default)]
1622    pub dot_color: Option<String>,
1623    /// Remaining duration in seconds (`None` when permanent while-equipped).
1624    #[serde(default)]
1625    pub remaining_sec: Option<f32>,
1626    /// Concurrent stacks of this effect (1 when not stacking).
1627    #[serde(default = "default_stack_count")]
1628    pub stack_count: u8,
1629}
1630
1631fn default_stack_count() -> u8 {
1632    1
1633}
1634
1635/// Observer combat HUD (local player only).
1636#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1637pub struct CombatTargetHud {
1638    pub entity_id: EntityId,
1639    #[serde(default)]
1640    pub label: String,
1641    #[serde(default)]
1642    pub level: u32,
1643    pub health: f32,
1644    pub health_max: f32,
1645    #[serde(default)]
1646    pub life_state: LifeState,
1647    #[serde(default)]
1648    pub distance_m: f32,
1649    #[serde(default)]
1650    pub statuses: Vec<StatusEffectHud>,
1651}
1652
1653/// Kind of in-world timed channel (farming, crafting stubs, …).
1654#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1655#[serde(rename_all = "snake_case")]
1656pub enum TimedChannelKind {
1657    #[default]
1658    Cultivate,
1659    Plant,
1660    Harvest,
1661    /// Timed player-building craft on a plot tilled pad.
1662    Build,
1663    /// Inventory craft channel (n-menu recipes).
1664    Craft,
1665}
1666
1667/// Local-player timed action (till, plant, …) — same progress shape as [`CastProgressHud`].
1668#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1669pub struct TimedChannelHud {
1670    #[serde(default)]
1671    pub label: String,
1672    #[serde(default)]
1673    pub channel: TimedChannelKind,
1674    #[serde(default)]
1675    pub cell_x: i32,
1676    #[serde(default)]
1677    pub cell_y: i32,
1678    /// Optional world AABB for multi-cell channels (plot build pad). Zero = unused.
1679    #[serde(default)]
1680    pub x0: f32,
1681    #[serde(default)]
1682    pub y0: f32,
1683    #[serde(default)]
1684    pub x1: f32,
1685    #[serde(default)]
1686    pub y1: f32,
1687    #[serde(default)]
1688    pub ticks_remaining: u64,
1689    #[serde(default)]
1690    pub ticks_total: u64,
1691}
1692
1693impl TimedChannelHud {
1694    /// True when this channel carries a drawable footprint AABB.
1695    pub fn has_footprint(&self) -> bool {
1696        self.x1 > self.x0 && self.y1 > self.y0
1697    }
1698}
1699
1700/// Where plot-build materials are drawn from (plan 20 §18).
1701#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1702#[serde(rename_all = "snake_case")]
1703pub enum PlotBuildMaterialSource {
1704    #[default]
1705    None,
1706    TownStorage,
1707    NearbyContainer,
1708}
1709
1710/// One catalog pack selectable as walls and/or roof.
1711#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1712pub struct BuildingMaterialView {
1713    pub id: String,
1714    pub display_name: String,
1715    #[serde(default)]
1716    pub can_wall: bool,
1717    #[serde(default)]
1718    pub can_roof: bool,
1719    #[serde(default)]
1720    pub wall_set: String,
1721    #[serde(default)]
1722    pub roof_set: String,
1723    #[serde(default = "default_material_tick_mult")]
1724    pub tick_mult: f32,
1725    #[serde(default)]
1726    pub wall_bom: Vec<BuildingBomLineView>,
1727    #[serde(default)]
1728    pub roof_bom: Vec<BuildingBomLineView>,
1729}
1730
1731fn default_material_tick_mult() -> f32 {
1732    1.0
1733}
1734
1735#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1736pub struct BuildingBomLineView {
1737    pub template_id: String,
1738    #[serde(default)]
1739    pub display_name: String,
1740    pub per_m2: f32,
1741}
1742
1743/// Available qty of one template in the eligible build material pool.
1744#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1745pub struct PlotBuildStockView {
1746    pub template_id: String,
1747    #[serde(default)]
1748    pub display_name: String,
1749    pub quantity: u32,
1750}
1751
1752/// Pad + storage pool for the B build menu (owned plot underfoot, no building yet).
1753#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1754pub struct PlotBuildOfferHud {
1755    pub plot_id: Uuid,
1756    #[serde(default)]
1757    pub pad_width_m: f32,
1758    #[serde(default)]
1759    pub pad_depth_m: f32,
1760    #[serde(default)]
1761    pub pad_ok: bool,
1762    #[serde(default)]
1763    pub pad_error: String,
1764    #[serde(default)]
1765    pub source: PlotBuildMaterialSource,
1766    #[serde(default)]
1767    pub source_label: String,
1768    #[serde(default)]
1769    pub available: Vec<PlotBuildStockView>,
1770    #[serde(default)]
1771    pub base_ticks: u32,
1772    #[serde(default)]
1773    pub tick_per_m2: u32,
1774}
1775
1776/// Active spell cast channel progress.
1777#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1778pub struct CastProgressHud {
1779    #[serde(default)]
1780    pub ability_id: String,
1781    #[serde(default)]
1782    pub ability_label: String,
1783    #[serde(default)]
1784    pub ticks_remaining: u64,
1785    #[serde(default)]
1786    pub ticks_total: u64,
1787}
1788
1789/// Cooldown state for a combat ability shown on the action bar.
1790#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1791pub struct AbilityCooldownHud {
1792    #[serde(default)]
1793    pub ability_id: String,
1794    #[serde(default)]
1795    pub label: String,
1796    #[serde(default)]
1797    pub cd_ticks: u64,
1798    #[serde(default)]
1799    pub cd_total_ticks: u64,
1800}
1801
1802/// One combat target slot in the observer HUD.
1803#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1804pub struct CombatSlotHud {
1805    pub slot_index: u8,
1806    #[serde(default)]
1807    pub target_entity_id: Option<EntityId>,
1808    #[serde(default)]
1809    pub target_label: Option<String>,
1810    #[serde(default)]
1811    pub target: Option<CombatTargetHud>,
1812    #[serde(default)]
1813    pub preset_id: Option<String>,
1814    #[serde(default)]
1815    pub preset_label: Option<String>,
1816    #[serde(default)]
1817    pub rotation: Vec<String>,
1818    #[serde(default)]
1819    pub rotation_index: u32,
1820    #[serde(default)]
1821    pub next_ability_id: Option<String>,
1822    #[serde(default)]
1823    pub auto_enabled: bool,
1824}
1825
1826/// One worn piece contribution in the defense breakdown.
1827#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1828pub struct DefensePieceHud {
1829    pub slot: BodySlot,
1830    pub label: String,
1831    pub template_id: String,
1832    #[serde(default)]
1833    pub armor_physical: f32,
1834    #[serde(default)]
1835    pub resists: Vec<(String, f32)>,
1836}
1837
1838impl Default for DefensePieceHud {
1839    fn default() -> Self {
1840        Self {
1841            slot: BodySlot::Head,
1842            label: String::new(),
1843            template_id: String::new(),
1844            armor_physical: 0.0,
1845            resists: Vec::new(),
1846        }
1847    }
1848}
1849
1850/// Aggregated mitigation / resist summary for the local player Equip UI.
1851#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1852pub struct DefenseHud {
1853    pub armor_physical: f32,
1854    pub vitality_contribution: f32,
1855    pub total_mitigation_rating: f32,
1856    /// `rating / (rating + K)` physical damage reduction fraction.
1857    pub estimated_physical_dr: f32,
1858    #[serde(default)]
1859    pub resists: Vec<(String, f32)>,
1860    #[serde(default)]
1861    pub pieces: Vec<DefensePieceHud>,
1862}
1863
1864/// Observer combat HUD (local player only).
1865#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1866pub struct CombatHud {
1867    pub in_combat: bool,
1868    /// T1 auto rotation (legacy field mirrors slot 1).
1869    pub auto_attack: bool,
1870    pub has_los: bool,
1871    pub attack_cd_ticks: u64,
1872    #[serde(default)]
1873    pub ability_id: String,
1874    #[serde(default)]
1875    pub target_entity_id: Option<EntityId>,
1876    #[serde(default)]
1877    pub target_label: Option<String>,
1878    #[serde(default)]
1879    pub max_target_slots: u8,
1880    #[serde(default)]
1881    pub slots: Vec<CombatSlotHud>,
1882    #[serde(default)]
1883    pub rotation_presets: Vec<RotationPreset>,
1884    #[serde(default)]
1885    pub gcd_ticks: u64,
1886    #[serde(default)]
1887    pub mainhand_template_id: Option<String>,
1888    #[serde(default)]
1889    pub mainhand_label: Option<String>,
1890    /// Specific mainhand instance when known (bindings / unique gear).
1891    #[serde(default)]
1892    pub mainhand_instance_id: Option<Uuid>,
1893    #[serde(default)]
1894    pub offhand_template_id: Option<String>,
1895    #[serde(default)]
1896    pub offhand_label: Option<String>,
1897    /// Specific offhand instance when known.
1898    #[serde(default)]
1899    pub offhand_instance_id: Option<Uuid>,
1900    /// Mainhand occupies this many hand sockets (`1` or `2`).
1901    #[serde(default)]
1902    pub mainhand_hand_slots: u8,
1903    /// Equipped body-slot items (backpack, belt, armor, jewelry).
1904    #[serde(default)]
1905    pub worn: Vec<(BodySlot, ItemStack)>,
1906    /// Live mitigation / resist summary for the Equip paperdoll.
1907    #[serde(default)]
1908    pub defense: Option<DefenseHud>,
1909    #[serde(default)]
1910    pub carry_mass: f32,
1911    #[serde(default)]
1912    pub carry_mass_max: f32,
1913    #[serde(default)]
1914    pub encumbrance: EncumbranceState,
1915    /// Keys on the virtual keychain (stowed, zero carry mass).
1916    #[serde(default)]
1917    pub keychain: Vec<ItemStack>,
1918    /// Paired whisper stones on the virtual pouch (stowed, zero carry mass).
1919    #[serde(default)]
1920    pub whisper_pouch: Vec<ItemStack>,
1921    #[serde(default)]
1922    pub target: Option<CombatTargetHud>,
1923    #[serde(default)]
1924    pub cast: Option<CastProgressHud>,
1925    /// Non-combat timed channel on the local player (till, plant, …).
1926    #[serde(default)]
1927    pub timed_channel: Option<TimedChannelHud>,
1928    /// When standing on an owned plot with no building: pad + material pool for the B menu.
1929    #[serde(default)]
1930    pub plot_build: Option<PlotBuildOfferHud>,
1931    #[serde(default)]
1932    pub ability_cooldowns: Vec<AbilityCooldownHud>,
1933    #[serde(default)]
1934    pub blocking_active: bool,
1935    /// Live XP pools for the local player (updated every tick with combat HUD).
1936    #[serde(default)]
1937    pub progression_xp: Option<ProgressionXp>,
1938    #[serde(default)]
1939    pub progression_baseline: u16,
1940    #[serde(default)]
1941    pub progression_xp_base: f64,
1942    #[serde(default)]
1943    pub progression_xp_growth: f64,
1944    #[serde(default)]
1945    pub attributes: Option<PrimaryAttributes>,
1946    #[serde(default)]
1947    pub skills: Option<PlayerSkills>,
1948    /// Active status effects on the local player.
1949    #[serde(default)]
1950    pub statuses: Vec<StatusEffectHud>,
1951    /// Learned abilities currently usable (permanent + unexpired temporary).
1952    #[serde(default)]
1953    pub known_abilities: Vec<String>,
1954    /// Aim / blast metadata for known abilities (ground cast UX).
1955    #[serde(default)]
1956    pub ability_meta: Vec<AbilityMetaHud>,
1957    /// Per-ability mastery for known abilities (tier / XP).
1958    #[serde(default)]
1959    pub ability_mastery: Vec<AbilityMasteryHud>,
1960    /// Hotbar bindings for keys 1–9 (index 0 = key 1).
1961    /// Ability ids, or `item:<template_id>` for consumables ([`hotbar_consumable_binding`]).
1962    #[serde(default)]
1963    pub hotbar: Vec<Option<String>>,
1964    /// Max abilities allowed in one rotation (INT+WIS mind score).
1965    #[serde(default)]
1966    pub max_abilities_per_rotation: u8,
1967    /// Effective walk speed after encumbrance, DEX, and status (m/s). Not sneak/sprint.
1968    #[serde(default)]
1969    pub move_speed_mps: f32,
1970    /// Load/status multiplier on base walk (`1.0` = unencumbered). `0` = unknown.
1971    #[serde(default)]
1972    pub move_speed_mult: f32,
1973}
1974
1975/// Client-facing ability aiming hints (synced on combat HUD).
1976#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1977pub struct AbilityMetaHud {
1978    pub id: String,
1979    /// `entity` | `ground` | `either`
1980    #[serde(default = "default_aim_mode_entity")]
1981    pub aim_mode: String,
1982    #[serde(default)]
1983    pub blast_radius_m: f32,
1984    #[serde(default)]
1985    pub allows_self: bool,
1986    #[serde(default)]
1987    pub is_heal: bool,
1988    /// When false, rotation editor / presets should not include this ability
1989    /// (hotbar / direct cast still allowed). Default true for older snapshots.
1990    #[serde(default = "default_auto_rotation_eligible")]
1991    pub auto_rotation_eligible: bool,
1992}
1993
1994fn default_auto_rotation_eligible() -> bool {
1995    true
1996}
1997
1998fn default_aim_mode_entity() -> String {
1999    "entity".into()
2000}
2001
2002/// Prefix for hotbar slots bound to inventory consumables (`item:health_potion`).
2003pub const HOTBAR_ITEM_PREFIX: &str = "item:";
2004
2005/// Encode a consumable template id for [`Intent::SetHotbarSlot`] / combat HUD hotbar.
2006pub fn hotbar_consumable_binding(template_id: &str) -> String {
2007    format!("{HOTBAR_ITEM_PREFIX}{}", template_id.trim())
2008}
2009
2010/// If `binding` is an `item:` consumable slot, return the template id.
2011pub fn hotbar_consumable_template(binding: &str) -> Option<&str> {
2012    binding
2013        .strip_prefix(HOTBAR_ITEM_PREFIX)
2014        .map(str::trim)
2015        .filter(|id| !id.is_empty())
2016}
2017
2018/// True when a hotbar binding string refers to a consumable (not an ability).
2019pub fn hotbar_binding_is_consumable(binding: &str) -> bool {
2020    hotbar_consumable_template(binding).is_some()
2021}
2022
2023/// Spatial combat cue for map overlays (`plans/39`).
2024#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2025#[serde(rename_all = "snake_case")]
2026pub enum CombatFxKind {
2027    MeleeArc,
2028    Cone,
2029    Sphere,
2030    Beam,
2031    HitMarker,
2032}
2033
2034/// Outcome attached to a hit marker / struck entity.
2035#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2036#[serde(rename_all = "snake_case")]
2037pub enum CombatFxHitOutcome {
2038    #[default]
2039    Hit,
2040    Blocked,
2041    Miss,
2042    Glance,
2043}
2044
2045/// One entity struck (or targeted) by a combat FX resolve.
2046#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2047pub struct CombatFxHit {
2048    pub entity_id: EntityId,
2049    pub x: f32,
2050    pub y: f32,
2051    pub z: f32,
2052    #[serde(default)]
2053    pub outcome: CombatFxHitOutcome,
2054}
2055
2056/// Authoritative attack footprint / hit cue for clients to draw on the map.
2057#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2058pub struct CombatFx {
2059    pub id: u64,
2060    pub kind: CombatFxKind,
2061    pub ability_id: String,
2062    pub caster_id: EntityId,
2063    pub origin_x: f32,
2064    pub origin_y: f32,
2065    pub origin_z: f32,
2066    #[serde(default)]
2067    pub end_x: Option<f32>,
2068    #[serde(default)]
2069    pub end_y: Option<f32>,
2070    #[serde(default)]
2071    pub end_z: Option<f32>,
2072    #[serde(default)]
2073    pub yaw: Option<f32>,
2074    #[serde(default)]
2075    pub reach_m: Option<f32>,
2076    #[serde(default)]
2077    pub arc_deg: Option<f32>,
2078    #[serde(default)]
2079    pub radius_m: Option<f32>,
2080    #[serde(default)]
2081    pub hits: Vec<CombatFxHit>,
2082    /// Sim tick after which clients should drop this cue.
2083    pub until_tick: u64,
2084    #[serde(default)]
2085    pub damage_type: String,
2086}
2087
2088/// Lingering ground hazard (boss `leave_hazard` puddle) visible until `expires_at_tick`.
2089#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2090pub struct GroundHazardView {
2091    pub x: f32,
2092    pub y: f32,
2093    pub z: f32,
2094    pub radius_m: f32,
2095    pub expires_at_tick: u64,
2096    #[serde(default)]
2097    pub damage_type: String,
2098}
2099
2100/// Hired worker automation mode on the wire.
2101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2102#[serde(rename_all = "snake_case")]
2103pub enum WorkerModeView {
2104    Companion,
2105    Defender,
2106    JobLoop,
2107    /// Deliberately parked — no route, no follow. The worker stands down
2108    /// (still draws wages) until the employer assigns a mode/route again.
2109    Idle,
2110}
2111
2112/// Hired worker FSM state on the wire.
2113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2114#[serde(rename_all = "snake_case")]
2115pub enum WorkerStateView {
2116    Idle,
2117    Traveling,
2118    Working,
2119    Resting,
2120    Waiting,
2121    Strike,
2122    Dismissed,
2123}
2124
2125/// Compact vitals for hired worker HUD rows and proximity overlay.
2126#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
2127pub struct WorkerVitalsSummary {
2128    pub health_pct: f32,
2129    pub stamina_pct: f32,
2130    #[serde(default)]
2131    pub mana_pct: f32,
2132    #[serde(default)]
2133    pub hunger_pct: f32,
2134    #[serde(default)]
2135    pub thirst_pct: f32,
2136}
2137
2138/// High-level route shape — `harvest_loop` (legacy flat lists) or `ordered` (typed stop list).
2139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2140#[serde(rename_all = "snake_case")]
2141pub enum WorkerRouteKindView {
2142    #[default]
2143    HarvestLoop,
2144    Ordered,
2145}
2146
2147/// Saved worker route for UI / route editor reload.
2148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
2149pub struct WorkerRouteView {
2150    #[serde(default)]
2151    pub kind: WorkerRouteKindView,
2152    #[serde(default)]
2153    pub lodging_container_id: Option<String>,
2154    /// Legacy `harvest_loop` waypoint list.
2155    #[serde(default)]
2156    pub outbound_waypoints: Vec<WorkerRouteWaypointView>,
2157    /// Legacy `harvest_loop` node id list.
2158    #[serde(default)]
2159    pub harvest_nodes: Vec<String>,
2160    #[serde(default = "default_route_carry_ratio")]
2161    pub carry_return_ratio: f32,
2162    /// Ordered-route typed stops (`kind: ordered`).
2163    #[serde(default)]
2164    pub stops: Vec<WorkerRouteStopView>,
2165}
2166
2167fn default_route_carry_ratio() -> f32 {
2168    0.90
2169}
2170
2171fn default_true_view() -> bool {
2172    true
2173}
2174
2175/// One withdraw line for a `WithdrawFrom` route stop view.
2176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2177pub struct WorkerWithdrawItemView {
2178    pub template: String,
2179    /// Hold-up-to count (top-up each loop). Ignored when `all` is set.
2180    #[serde(default)]
2181    pub qty: u32,
2182    /// Take every stack of this template (carry-capped). Wins over `qty`.
2183    #[serde(default)]
2184    pub all: bool,
2185}
2186
2187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
2188pub struct WorkerRouteWaypointView {
2189    pub x: f32,
2190    pub y: f32,
2191    pub z: f32,
2192}
2193
2194/// One typed stop in an ordered worker route.
2195///
2196/// NOTE: this is the wire (postcard) view. Postcard does **not** support
2197/// internally-tagged enums (`#[serde(tag = ...)]` — it returns `WontImplement`
2198/// from `deserialize_any`), so this enum uses serde's default **external tagging**.
2199/// The sim-side `WorkerRouteStop` (`crates/sim/src/worker_job.rs`) is the YAML-facing
2200/// twin and keeps its `tag = "stop"` for human-authored job YAML; the two never share
2201/// a wire format.
2202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2203#[serde(rename_all = "snake_case")]
2204pub enum WorkerRouteStopView {
2205    Waypoint {
2206        x: f32,
2207        y: f32,
2208        #[serde(default)]
2209        z: f32,
2210    },
2211    HarvestNode {
2212        node_id: String,
2213    },
2214    DepositAt {
2215        container_id: String,
2216        #[serde(default)]
2217        filter: Option<Vec<String>>,
2218    },
2219    TradeWith {
2220        #[serde(default)]
2221        npc_id: Option<String>,
2222        template: String,
2223        #[serde(default = "default_true_view")]
2224        sell_all: bool,
2225    },
2226    /// List held stacks on a market hall at NPC price (dump queue).
2227    ListOnMarket {
2228        template: String,
2229        #[serde(default = "default_true_view")]
2230        list_all: bool,
2231        #[serde(default)]
2232        hall_id: Option<String>,
2233    },
2234    WithdrawFrom {
2235        container_id: String,
2236        items: Vec<WorkerWithdrawItemView>,
2237    },
2238    CraftAt {
2239        device: String,
2240        blueprint: String,
2241        #[serde(default)]
2242        qty: Option<u32>,
2243    },
2244    CultivatePlot {
2245        plot_id: uuid::Uuid,
2246    },
2247    PlantPlot {
2248        plot_id: uuid::Uuid,
2249        seed_template: String,
2250    },
2251    HarvestPlot {
2252        plot_id: uuid::Uuid,
2253    },
2254    RestIfNeeded,
2255    Wait {
2256        #[serde(default)]
2257        wait_ticks: u64,
2258    },
2259}
2260
2261/// Copper ledger category (expense negative / income positive on the wire entry).
2262#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2263#[serde(rename_all = "snake_case")]
2264pub enum LedgerCategory {
2265    Workers,
2266    Hire,
2267    Train,
2268    ShopBuy,
2269    Taxes,
2270    WorkerSales,
2271    TraderSales,
2272    BankDeposit,
2273    BankWithdraw,
2274    BankTransferOut,
2275    BankTransferIn,
2276    BankTransferFee,
2277    StorageShipFee,
2278    /// Crown or private purchase of a property deed.
2279    PropertyBuy,
2280    /// Crown buyback or private sale of a property deed.
2281    PropertySell,
2282    /// Landlord share of harvest tax paid on an owned plot.
2283    TaxShare,
2284    /// Bank debit for a market-hall purchase (`plans/10-economy-and-markets.md`).
2285    MarketBuy,
2286    /// Bank credit for a market-hall sale (net of crown sales tax).
2287    MarketSell,
2288    Other,
2289}
2290
2291impl LedgerCategory {
2292    pub fn as_str(self) -> &'static str {
2293        match self {
2294            Self::Workers => "workers",
2295            Self::Hire => "hire",
2296            Self::Train => "train",
2297            Self::ShopBuy => "shop_buy",
2298            Self::Taxes => "taxes",
2299            Self::WorkerSales => "worker_sales",
2300            Self::TraderSales => "trader_sales",
2301            Self::BankDeposit => "bank_deposit",
2302            Self::BankWithdraw => "bank_withdraw",
2303            Self::BankTransferOut => "bank_transfer_out",
2304            Self::BankTransferIn => "bank_transfer_in",
2305            Self::BankTransferFee => "bank_transfer_fee",
2306            Self::StorageShipFee => "storage_ship_fee",
2307            Self::PropertyBuy => "property_buy",
2308            Self::PropertySell => "property_sell",
2309            Self::TaxShare => "tax_share",
2310            Self::MarketBuy => "market_buy",
2311            Self::MarketSell => "market_sell",
2312            Self::Other => "other",
2313        }
2314    }
2315}
2316
2317#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2318pub struct LedgerEntryView {
2319    pub id: uuid::Uuid,
2320    pub game_day: u64,
2321    pub signed_copper: i64,
2322    pub category: LedgerCategory,
2323    #[serde(default)]
2324    pub label: String,
2325}
2326
2327#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
2328pub struct LedgerPeriodTotals {
2329    /// Absolute copper spent by category key.
2330    #[serde(default)]
2331    pub expenses: std::collections::HashMap<String, u64>,
2332    /// Absolute copper earned by category key.
2333    #[serde(default)]
2334    pub income: std::collections::HashMap<String, u64>,
2335    pub expense_copper: u64,
2336    pub income_copper: u64,
2337    /// income − expenses (may be negative).
2338    pub cash_flow_copper: i64,
2339}
2340
2341#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
2342pub struct PlayerLedgerView {
2343    pub current_game_day: u64,
2344    #[serde(default)]
2345    pub period_day: LedgerPeriodTotals,
2346    #[serde(default)]
2347    pub period_week: LedgerPeriodTotals,
2348    #[serde(default)]
2349    pub period_month: LedgerPeriodTotals,
2350    #[serde(default)]
2351    pub period_lifetime: LedgerPeriodTotals,
2352    #[serde(default)]
2353    pub recent: Vec<LedgerEntryView>,
2354    /// Copper on the character (inventory + worn bags).
2355    #[serde(default)]
2356    pub wealth_on_person_copper: u64,
2357    /// Copper in owned placed storage (chests, lodging — not bank ledger).
2358    #[serde(default)]
2359    pub wealth_in_storage_copper: u64,
2360    /// Copper in the secure bank ledger (`plans/08` §8).
2361    #[serde(default)]
2362    pub wealth_in_bank_copper: u64,
2363    /// `wealth_on_person_copper + wealth_in_storage_copper + wealth_in_bank_copper`.
2364    #[serde(default)]
2365    pub wealth_total_copper: u64,
2366    /// Sum of purchase-basis copper for deeds this character currently holds (asset book value).
2367    #[serde(default)]
2368    pub wealth_in_property_copper: u64,
2369    /// Liquid copper + property book value.
2370    #[serde(default)]
2371    pub wealth_net_worth_copper: u64,
2372    /// Deeds held (inventory, worn bags, town vault, owned chests) with book values.
2373    #[serde(default)]
2374    pub property_assets: Vec<PropertyAssetView>,
2375    /// Recent property sales near plots you hold (comps for a local market).
2376    #[serde(default)]
2377    pub property_market_nearby: Vec<PropertyMarketCompView>,
2378    /// Live payroll burn (cp per wage interval) for hired workers.
2379    #[serde(default)]
2380    pub live_expense_per_interval_copper: u64,
2381    /// Estimated copper from one full worker job loop (broker sell steps).
2382    #[serde(default)]
2383    pub live_income_route_est_per_loop_copper: u64,
2384    /// Recent average income (worker/trader sales) per wage interval.
2385    #[serde(default)]
2386    pub live_income_avg_per_interval_copper: u64,
2387    /// Number of wage intervals in the rolling average window.
2388    #[serde(default)]
2389    pub live_income_avg_window_intervals: u32,
2390    /// `live_income_avg_per_interval_copper − live_expense_per_interval_copper`.
2391    #[serde(default)]
2392    pub live_net_avg_per_interval_copper: i64,
2393}
2394
2395/// One deed currently held by the character (ledger asset line).
2396#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2397pub struct PropertyAssetView {
2398    pub plot_id: Uuid,
2399    /// Friendly deed label (`{Owner}'s {Zone} deed @ (x, y) (N m²)`).
2400    pub label: String,
2401    pub zone_id: String,
2402    #[serde(default)]
2403    pub zone_label: Option<String>,
2404    pub area_m2: f32,
2405    /// Book value (what you paid / last private sale price).
2406    pub purchase_basis_copper: u64,
2407    pub upkeep_copper_per_day: u64,
2408}
2409
2410/// A recorded property sale near the observer's holdings.
2411#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2412pub struct PropertyMarketCompView {
2413    pub day: u64,
2414    pub zone_id: String,
2415    #[serde(default)]
2416    pub zone_label: Option<String>,
2417    pub area_m2: f32,
2418    pub price_copper: u64,
2419    /// `price_copper / area_m2` (0 when area is tiny).
2420    pub price_per_m2_copper: u64,
2421    /// `crown_purchase` | `crown_buyback` | `player_trade`.
2422    pub kind: String,
2423    /// Distance from the nearest plot you hold (meters).
2424    pub distance_m: f32,
2425}
2426
2427/// Gameplay analytics metric keys (extensible string on the wire via rename).
2428#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2429#[serde(rename_all = "snake_case")]
2430pub enum AnalyticsMetric {
2431    NpcKill,
2432    WildlifeKill,
2433    Harvest,
2434    QuestComplete,
2435    QuestAccept,
2436    QuestAbandon,
2437    PlayerDeath,
2438    Craft,
2439    WorkerHire,
2440    WorkerDismiss,
2441    WorkerTeach,
2442    NpcTalk,
2443    ShopBuy,
2444    ShopSell,
2445    PlaceContainer,
2446    PickupContainer,
2447    PickupDrop,
2448    ConsumableUse,
2449    AbilityUse,
2450    DistanceWalkedM,
2451    DoorUse,
2452    BuildingEnter,
2453}
2454
2455impl AnalyticsMetric {
2456    pub fn as_str(self) -> &'static str {
2457        match self {
2458            Self::NpcKill => "npc_kill",
2459            Self::WildlifeKill => "wildlife_kill",
2460            Self::Harvest => "harvest",
2461            Self::QuestComplete => "quest_complete",
2462            Self::QuestAccept => "quest_accept",
2463            Self::QuestAbandon => "quest_abandon",
2464            Self::PlayerDeath => "player_death",
2465            Self::Craft => "craft",
2466            Self::WorkerHire => "worker_hire",
2467            Self::WorkerDismiss => "worker_dismiss",
2468            Self::WorkerTeach => "worker_teach",
2469            Self::NpcTalk => "npc_talk",
2470            Self::ShopBuy => "shop_buy",
2471            Self::ShopSell => "shop_sell",
2472            Self::PlaceContainer => "place_container",
2473            Self::PickupContainer => "pickup_container",
2474            Self::PickupDrop => "pickup_drop",
2475            Self::ConsumableUse => "consumable_use",
2476            Self::AbilityUse => "ability_use",
2477            Self::DistanceWalkedM => "distance_walked_m",
2478            Self::DoorUse => "door_use",
2479            Self::BuildingEnter => "building_enter",
2480        }
2481    }
2482
2483    pub fn from_str_key(s: &str) -> Option<Self> {
2484        Some(match s {
2485            "npc_kill" => Self::NpcKill,
2486            "wildlife_kill" => Self::WildlifeKill,
2487            "harvest" => Self::Harvest,
2488            "quest_complete" => Self::QuestComplete,
2489            "quest_accept" => Self::QuestAccept,
2490            "quest_abandon" => Self::QuestAbandon,
2491            "player_death" => Self::PlayerDeath,
2492            "craft" => Self::Craft,
2493            "worker_hire" => Self::WorkerHire,
2494            "worker_dismiss" => Self::WorkerDismiss,
2495            "worker_teach" => Self::WorkerTeach,
2496            "npc_talk" => Self::NpcTalk,
2497            "shop_buy" => Self::ShopBuy,
2498            "shop_sell" => Self::ShopSell,
2499            "place_container" => Self::PlaceContainer,
2500            "pickup_container" => Self::PickupContainer,
2501            "pickup_drop" => Self::PickupDrop,
2502            "consumable_use" => Self::ConsumableUse,
2503            "ability_use" => Self::AbilityUse,
2504            "distance_walked_m" => Self::DistanceWalkedM,
2505            "door_use" => Self::DoorUse,
2506            "building_enter" => Self::BuildingEnter,
2507            _ => return None,
2508        })
2509    }
2510}
2511
2512#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2513pub struct CareerMetricRow {
2514    pub subject_id: String,
2515    pub amount: u64,
2516}
2517
2518/// Personal analytics summary for the Character `i` Career tab.
2519#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
2520pub struct PlayerCareerView {
2521    pub current_game_day: u64,
2522    #[serde(default)]
2523    pub kills: Vec<CareerMetricRow>,
2524    #[serde(default)]
2525    pub harvests: Vec<CareerMetricRow>,
2526    pub quests_completed: u64,
2527    #[serde(default)]
2528    pub crafts: Vec<CareerMetricRow>,
2529    pub deaths: u64,
2530    pub npc_talks: u64,
2531    pub shop_buys: u64,
2532    pub shop_sells: u64,
2533    pub distance_m: u64,
2534    #[serde(default)]
2535    pub other: Vec<CareerMetricRow>,
2536}
2537
2538/// Equipment currently worn by a player-hired worker.
2539///
2540/// Equipped items are separate from `HiredWorkerView::inventory`; the latter
2541/// contains only the worker's loose/root pack contents.
2542#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
2543pub struct WorkerEquipmentView {
2544    #[serde(default)]
2545    pub mainhand: Option<ItemStack>,
2546    #[serde(default)]
2547    pub offhand: Option<ItemStack>,
2548    #[serde(default)]
2549    pub worn: Vec<(BodySlot, ItemStack)>,
2550}
2551
2552/// One player-hired worker visible in snapshot / tick deltas.
2553#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2554pub struct HiredWorkerView {
2555    pub instance_id: String,
2556    pub entity_id: EntityId,
2557    pub def_id: String,
2558    /// Display label (custom name when set, otherwise the NPC def label).
2559    pub label: String,
2560    pub x: f32,
2561    pub y: f32,
2562    pub z: f32,
2563    pub mode: WorkerModeView,
2564    pub state: WorkerStateView,
2565    #[serde(default)]
2566    pub step_label: String,
2567    pub vitals: WorkerVitalsSummary,
2568    #[serde(default)]
2569    pub carry_pct: f32,
2570    #[serde(default)]
2571    pub last_error: Option<String>,
2572    pub wage_copper_per_interval: u32,
2573    /// Estimated wage this interval (base + loop effort, travel meters excluded).
2574    #[serde(default)]
2575    pub effective_wage_copper: u32,
2576    /// Meters walked toward the next wage debit.
2577    #[serde(default)]
2578    pub wage_meters_walked: f32,
2579    /// Placed camp bed / lodging container this worker uses for deposit and rest.
2580    #[serde(default)]
2581    pub lodging_container_id: Option<String>,
2582    /// High-level harvest route (when job_loop route is configured).
2583    #[serde(default)]
2584    pub route: Option<WorkerRouteView>,
2585    /// Index into `route.stops` for the worker's current job step (ordered routes).
2586    /// Maps expanded job steps (travel+deposit, etc.) back to the designer stop.
2587    #[serde(default)]
2588    pub route_stop_index: Option<u32>,
2589    /// Recipes this worker already knows (from hire `teaches` + employer teach).
2590    #[serde(default)]
2591    pub known_blueprint_ids: Vec<String>,
2592    /// Overall worker level (1+).
2593    #[serde(default = "default_worker_view_level")]
2594    pub level: u32,
2595    /// Cumulative worker XP.
2596    #[serde(default)]
2597    pub worker_xp: f64,
2598    /// Items the worker currently carries (employer-visible for give/take).
2599    #[serde(default)]
2600    pub inventory: Vec<ItemStack>,
2601    /// Items currently equipped by the worker, separate from `inventory`.
2602    #[serde(default)]
2603    pub equipment: WorkerEquipmentView,
2604    /// Short "what you should do" line when `last_error` is a player-actionable
2605    /// plan or logistics issue (missing chest, empty lodging, etc.).
2606    #[serde(default)]
2607    pub issue_hint: Option<String>,
2608}
2609
2610fn default_worker_view_level() -> u32 {
2611    1
2612}
2613
2614/// Server → client AOI-filtered entity updates for one sim tick.
2615#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2616pub struct TickDelta {
2617    pub tick: Tick,
2618    pub entities: Vec<EntityState>,
2619    #[serde(default)]
2620    pub resource_nodes: Vec<ResourceNodeView>,
2621    #[serde(default)]
2622    pub buildings: Vec<BuildingView>,
2623    #[serde(default)]
2624    pub doors: Vec<DoorView>,
2625    #[serde(default)]
2626    pub npcs: Vec<NpcView>,
2627    /// Observer inventory stacks (template → qty).
2628    #[serde(default)]
2629    pub inventory: Vec<ItemStack>,
2630    #[serde(default)]
2631    pub blueprints: Vec<BlueprintView>,
2632    /// Wall/roof material packs for the B plot-build menu.
2633    #[serde(default)]
2634    pub building_materials: Vec<BuildingMaterialView>,
2635    #[serde(default)]
2636    pub world_clock: WorldClock,
2637    #[serde(default)]
2638    pub ground_drops: Vec<GroundDropView>,
2639    #[serde(default)]
2640    pub placed_containers: Vec<PlacedContainerView>,
2641    #[serde(default)]
2642    pub combat: Option<CombatHud>,
2643    #[serde(default)]
2644    pub interior_map: Option<InteriorMapView>,
2645    #[serde(default)]
2646    pub quest_log: Vec<QuestLogEntry>,
2647    #[serde(default)]
2648    pub hired_workers: Vec<HiredWorkerView>,
2649    #[serde(default)]
2650    pub interactables: Vec<InteractableView>,
2651    #[serde(default)]
2652    pub ledger: Option<PlayerLedgerView>,
2653    #[serde(default)]
2654    pub career: Option<PlayerCareerView>,
2655    /// Active combat footprints / hit markers in observer AOI (`plans/39`).
2656    #[serde(default)]
2657    pub combat_fx: Vec<CombatFx>,
2658    /// Persistent ground hazards (boss puddles) in observer AOI.
2659    #[serde(default)]
2660    pub ground_hazards: Vec<GroundHazardView>,
2661    /// Claimed property plots near the observer (plan 40).
2662    #[serde(default)]
2663    pub property_plots: Vec<PropertyPlotView>,
2664    /// Runtime terrain cell overlays (cultivate dirt → tilled). Replaces prior overlays.
2665    #[serde(default)]
2666    pub terrain_overlays: Vec<TerrainZoneView>,
2667}
2668#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2669pub struct GroundDropView {
2670    pub id: String,
2671    pub template_id: String,
2672    pub quantity: u32,
2673    pub x: f32,
2674    pub y: f32,
2675    pub z: f32,
2676    /// Optional gfx tile id from the item template.
2677    #[serde(default)]
2678    pub tile_id: Option<String>,
2679    /// Item display name (e.g. "Cloth Pants") for hover/chat labels.
2680    #[serde(default)]
2681    pub display_name: Option<String>,
2682    /// Facing yaw in radians (0 = north) for sprite draw on the ground.
2683    #[serde(default)]
2684    pub yaw: f32,
2685    /// Pitch in radians (0 = upright).
2686    #[serde(default)]
2687    pub pitch: f32,
2688    /// Roll in radians (0 = upright; tilts in the view plane).
2689    #[serde(default)]
2690    pub roll: f32,
2691    /// Draw scale relative to one map cell (1.0 = cell size).
2692    #[serde(default = "default_draw_scale")]
2693    pub draw_scale: f32,
2694    /// Preserved unique instance id when the pile carries full slot state (`plans/18`).
2695    #[serde(default)]
2696    pub item_instance_id: Option<Uuid>,
2697    /// Instance props (custom name, deed metadata, etc.).
2698    #[serde(default)]
2699    pub props: std::collections::BTreeMap<String, String>,
2700    /// Enchant / status bindings on the unique instance.
2701    #[serde(default)]
2702    pub status_bindings: Vec<ItemStatusBinding>,
2703}
2704
2705fn default_draw_scale() -> f32 {
2706    1.0
2707}
2708
2709/// Full state on region enter or reconnect.
2710#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2711pub struct Snapshot {
2712    pub tick: Tick,
2713    pub chunk_rev: u64,
2714    /// Bumped when blueprints, catalog, segment, or settings reload.
2715    #[serde(default)]
2716    pub content_rev: u64,
2717    /// Stable publish revision from `assets/.content-publish.json` (for client asset sync).
2718    #[serde(default)]
2719    pub publish_rev: u64,
2720    pub entities: Vec<EntityState>,
2721    #[serde(default)]
2722    pub resource_nodes: Vec<ResourceNodeView>,
2723    /// Outdoor play AABB origin (meters). With width/height, defines the composed world.
2724    #[serde(default)]
2725    pub world_x0: f32,
2726    #[serde(default)]
2727    pub world_y0: f32,
2728    /// Segment play area width in meters (for HUD / beyond-zone).
2729    #[serde(default)]
2730    pub world_width_m: f32,
2731    #[serde(default)]
2732    pub world_height_m: f32,
2733    #[serde(default)]
2734    pub buildings: Vec<BuildingView>,
2735    #[serde(default)]
2736    pub doors: Vec<DoorView>,
2737    #[serde(default)]
2738    pub npcs: Vec<NpcView>,
2739    #[serde(default)]
2740    pub inventory: Vec<ItemStack>,
2741    #[serde(default)]
2742    pub blueprints: Vec<BlueprintView>,
2743    /// Wall/roof material packs for the B plot-build menu.
2744    #[serde(default)]
2745    pub building_materials: Vec<BuildingMaterialView>,
2746    #[serde(default)]
2747    pub world_clock: WorldClock,
2748    #[serde(default)]
2749    pub terrain_zones: Vec<TerrainZoneView>,
2750    #[serde(default)]
2751    pub z_platforms: Vec<ZPlatformView>,
2752    #[serde(default)]
2753    pub z_transitions: Vec<ZTransitionView>,
2754    #[serde(default)]
2755    pub ground_drops: Vec<GroundDropView>,
2756    #[serde(default)]
2757    pub placed_containers: Vec<PlacedContainerView>,
2758    #[serde(default)]
2759    pub combat: Option<CombatHud>,
2760    #[serde(default)]
2761    pub interior_map: Option<InteriorMapView>,
2762    #[serde(default)]
2763    pub quest_log: Vec<QuestLogEntry>,
2764    #[serde(default)]
2765    pub hired_workers: Vec<HiredWorkerView>,
2766    #[serde(default)]
2767    pub interactables: Vec<InteractableView>,
2768    #[serde(default)]
2769    pub ledger: Option<PlayerLedgerView>,
2770    #[serde(default)]
2771    pub career: Option<PlayerCareerView>,
2772    /// Active combat footprints / hit markers (`plans/39`).
2773    #[serde(default)]
2774    pub combat_fx: Vec<CombatFx>,
2775    /// Persistent ground hazards (boss puddles) in observer AOI.
2776    #[serde(default)]
2777    pub ground_hazards: Vec<GroundHazardView>,
2778    /// Authored crown property zones (claimable land) — plan 40.
2779    #[serde(default)]
2780    pub property_zones: Vec<PropertyZoneView>,
2781    /// Tax overlays (for claim cost premium preview).
2782    #[serde(default)]
2783    pub tax_zones: Vec<TaxZoneView>,
2784    /// Town / security / PvP boundaries (plan 43).
2785    #[serde(default)]
2786    pub boundary_zones: Vec<BoundaryZoneView>,
2787    /// Random-encounter rectangles (plan 54).
2788    #[serde(default)]
2789    pub encounter_zones: Vec<EncounterZoneView>,
2790    /// Growth / fertility overlays (plan 37 / farming).
2791    #[serde(default)]
2792    pub growth_zones: Vec<GrowthZoneView>,
2793    /// Climate / biome overlays (plan 38).
2794    #[serde(default)]
2795    pub biome_zones: Vec<BiomeZoneView>,
2796    /// Terrain kind move speed / impassable table for client pathfinding (from terrain-kinds.yaml).
2797    #[serde(default)]
2798    pub terrain_kind_nav: Vec<TerrainKindNavView>,
2799    /// Claimed property plots in this segment.
2800    #[serde(default)]
2801    pub property_plots: Vec<PropertyPlotView>,
2802    /// Server knobs needed for client claim quote preview.
2803    #[serde(default)]
2804    pub property_plot_settings: Option<PropertyPlotSettingsView>,
2805    /// Compact item catalog (labels + category) so pickers can resolve UUID
2806    /// template ids without relying on inventory-only hints.
2807    #[serde(default)]
2808    pub item_catalog: Vec<ItemCatalogEntryView>,
2809}
2810
2811/// Compact catalog row for client pickers (not a full item def).
2812#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2813pub struct ItemCatalogEntryView {
2814    pub template_id: String,
2815    #[serde(default)]
2816    pub display_name: String,
2817    #[serde(default)]
2818    pub category: String,
2819    /// When set, this template is a farm seed for the named crop.
2820    #[serde(default)]
2821    pub seed_for: Option<String>,
2822}
2823
2824impl ItemCatalogEntryView {
2825    pub fn is_harvest_node(&self) -> bool {
2826        self.category == "harvest_node"
2827    }
2828
2829    /// World harvest-node templates cannot exist as deposit/inventory stacks.
2830    pub fn is_depositable_stack(&self) -> bool {
2831        !self.is_harvest_node()
2832    }
2833
2834    pub fn is_farm_seed(&self) -> bool {
2835        self.seed_for
2836            .as_deref()
2837            .is_some_and(|s| !s.trim().is_empty())
2838            || self.category == "seed"
2839    }
2840}
2841
2842/// Harvestable node visible to clients.
2843#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2844pub struct ResourceNodeView {
2845    pub id: String,
2846    pub label: String,
2847    pub x: f32,
2848    pub y: f32,
2849    pub z: f32,
2850    pub item_template: String,
2851    #[serde(default = "default_node_state")]
2852    pub state: ResourceNodeState,
2853    /// When true, players cannot walk through this node while available/harvesting.
2854    #[serde(default = "default_blocking_view")]
2855    pub blocking: bool,
2856    /// Collision radius for pathfinding (meters).
2857    #[serde(default = "default_blocking_radius_view")]
2858    pub blocking_radius_m: f32,
2859    /// Decorative map placement — not harvestable; `blocking` is forced false on the wire.
2860    #[serde(default)]
2861    pub harvest_off: bool,
2862    /// Optional gfx tile id (`resource.oak_log`, …).
2863    #[serde(default)]
2864    pub tile_id: Option<String>,
2865    /// Facing yaw in radians (0 = north). From map placement.
2866    #[serde(default)]
2867    pub yaw: f32,
2868    /// Pitch in radians (0 = upright). From map placement.
2869    #[serde(default)]
2870    pub pitch: f32,
2871    /// Roll in radians (0 = upright). From map placement.
2872    #[serde(default)]
2873    pub roll: f32,
2874    /// Draw scale relative to one map cell (1.0 = cell size).
2875    #[serde(default = "default_draw_scale")]
2876    pub draw_scale: f32,
2877    /// Resolved gfx sprite mode for `tile_id` (server-computed).
2878    #[serde(default)]
2879    pub sprite_mode: Option<String>,
2880    /// Canonical presentation key (`available`, `harvesting`, `depleted`).
2881    #[serde(default)]
2882    pub presentation_state: Option<String>,
2883    /// Farm crop growth 0.0–1.0 while immature; `None` for other nodes and mature crops.
2884    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
2885    #[serde(default)]
2886    pub growth_progress: Option<f32>,
2887    /// Active harvest channel (sim ticks), for progress rings on the node.
2888    #[serde(default)]
2889    pub channel_start_tick: Option<Tick>,
2890    #[serde(default)]
2891    pub channel_end_tick: Option<Tick>,
2892    /// Possible loot templates from this node's harvest loot table (route deposit filters).
2893    #[serde(default)]
2894    pub harvest_drop_templates: Vec<String>,
2895}
2896
2897fn default_blocking_radius_view() -> f32 {
2898    0.8
2899}
2900
2901fn default_blocking_view() -> bool {
2902    true
2903}
2904
2905#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2906#[serde(rename_all = "snake_case")]
2907pub enum ResourceNodeState {
2908    Available,
2909    Harvesting,
2910    Cooldown,
2911}
2912fn default_node_state() -> ResourceNodeState {
2913    ResourceNodeState::Available
2914}
2915
2916/// Live state of a placed world item spawn (plan 45).
2917#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2918#[serde(rename_all = "snake_case")]
2919pub enum ItemSpawnStateView {
2920    Spawned,
2921    PickedUp { respawn_at_tick: u64 },
2922    Consumed,
2923}
2924
2925/// Authoritative view of a placed findable item (plan 45) for admin/overseer.
2926#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2927pub struct ItemSpawnView {
2928    pub id: String,
2929    pub label: String,
2930    pub item_template: String,
2931    pub quantity: u32,
2932    pub x: f32,
2933    pub y: f32,
2934    pub z: f32,
2935    pub respawn_ticks: u32,
2936    #[serde(default)]
2937    pub building_id: Option<String>,
2938    pub state: ItemSpawnStateView,
2939    /// Each character may pick this spawn once; the pile stays for others.
2940    #[serde(default)]
2941    pub once_per_character: bool,
2942    /// How many characters have already collected a once-per-character spawn.
2943    #[serde(default)]
2944    pub collected_count: u32,
2945}
2946
2947#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2948#[serde(rename_all = "snake_case")]
2949pub enum ItemStatusBindingMode {
2950    OnHit,
2951    WhileEquipped,
2952}
2953
2954impl Default for ItemStatusBindingMode {
2955    fn default() -> Self {
2956        Self::OnHit
2957    }
2958}
2959
2960/// Per-instance status effect binding (unique gear grants / enchantments).
2961#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2962pub struct ItemStatusBinding {
2963    pub effect_id: String,
2964    #[serde(default)]
2965    pub mode: ItemStatusBindingMode,
2966    /// Grant template id, `"loot"`, later `"altar"`, etc.
2967    #[serde(default)]
2968    pub source: String,
2969    #[serde(default)]
2970    pub applied_at_tick: u64,
2971    /// `None` = permanent until overwritten / dispelled.
2972    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
2973    #[serde(default)]
2974    pub expires_at_tick: Option<u64>,
2975}
2976
2977#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2978pub struct ItemStack {
2979    pub template_id: String,
2980    pub quantity: u32,
2981    /// Stable instance id — preserved across checkpoint/sync when set.
2982    #[serde(default)]
2983    pub item_instance_id: Option<Uuid>,
2984    /// Per-instance metadata (stat rolls, soul-bind, lock ids, etc.).
2985    #[serde(default)]
2986    pub props: BTreeMap<String, String>,
2987    /// Instance-only status effects (template effects live on the item def).
2988    #[serde(default)]
2989    pub status_bindings: Vec<ItemStatusBinding>,
2990    /// Nested contents when this stack is a container instance.
2991    #[serde(default)]
2992    pub contents: Vec<ItemStack>,
2993    /// From item catalog when sent on wire (display only).
2994    #[serde(default)]
2995    pub display_name: Option<String>,
2996    /// From item catalog when sent on wire (`weapon`, `consumable`, …).
2997    #[serde(default)]
2998    pub category: Option<String>,
2999    /// Per-unit mass in kg from catalog (display / move UX).
3000    #[serde(default)]
3001    pub base_mass: Option<f32>,
3002    /// Per-unit volume from catalog (display / move UX).
3003    #[serde(default)]
3004    pub base_volume: Option<f32>,
3005    /// Container capacity when this stack is a container template.
3006    #[serde(default)]
3007    pub capacity_volume: Option<f32>,
3008    /// Whether the template stacks in inventory (from catalog).
3009    #[serde(default)]
3010    pub stackable: Option<bool>,
3011    /// Can be placed on the ground from inventory (from catalog).
3012    #[serde(default)]
3013    pub world_placeable: Option<bool>,
3014    /// Hired-worker lodging capacity when this template is placed (from catalog).
3015    #[serde(default)]
3016    pub worker_lodging_capacity: Option<u32>,
3017    /// Body slot this template equips into (from catalog; display / Equip UI).
3018    #[serde(default)]
3019    pub equip_slot: Option<BodySlot>,
3020    /// Template baseline armor rating (from catalog).
3021    #[serde(default)]
3022    pub armor_physical: Option<f32>,
3023    /// Template resists damage_type → value (from catalog).
3024    #[serde(default)]
3025    pub resists: Vec<(String, f32)>,
3026    /// Weapon hand occupancy when category is weapon (`1` or `2`).
3027    #[serde(default)]
3028    pub hand_slots: Option<u8>,
3029    /// Market-hall list eligibility from catalog (display / client pickers).
3030    #[serde(default)]
3031    pub listable: Option<bool>,
3032    /// Catalog NPC trade base (`base_value_copper`) for dump-queue payout hints.
3033    #[serde(default)]
3034    pub base_value_copper: Option<u32>,
3035}
3036
3037impl ItemStack {
3038    pub fn simple(template_id: impl Into<String>, quantity: u32) -> Self {
3039        Self {
3040            template_id: template_id.into(),
3041            quantity,
3042            ..Default::default()
3043        }
3044    }
3045}
3046
3047impl Default for ItemStack {
3048    fn default() -> Self {
3049        Self {
3050            template_id: String::new(),
3051            quantity: 0,
3052            item_instance_id: None,
3053            props: BTreeMap::new(),
3054            status_bindings: Vec::new(),
3055            contents: Vec::new(),
3056            display_name: None,
3057            category: None,
3058            base_mass: None,
3059            base_volume: None,
3060            capacity_volume: None,
3061            stackable: None,
3062            world_placeable: None,
3063            worker_lodging_capacity: None,
3064            equip_slot: None,
3065            armor_physical: None,
3066            resists: Vec::new(),
3067            hand_slots: None,
3068            listable: None,
3069            base_value_copper: None,
3070        }
3071    }
3072}
3073
3074/// Carry encumbrance band (`plans/57`). HUD maps Light/Heavy/Orange/Over → green/yellow/orange/red.
3075#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3076#[serde(rename_all = "snake_case")]
3077pub enum EncumbranceState {
3078    #[default]
3079    Light,
3080    Heavy,
3081    Orange,
3082    Over,
3083}
3084
3085impl EncumbranceState {
3086    /// Player-facing band name (not a raw id).
3087    pub fn label(self) -> &'static str {
3088        match self {
3089            Self::Light => "Light",
3090            Self::Heavy => "Heavy",
3091            Self::Orange => "Overloaded",
3092            Self::Over => "Over",
3093        }
3094    }
3095
3096    /// Sprint is forbidden in the red (Over) band.
3097    pub fn allows_sprint(self) -> bool {
3098        !matches!(self, Self::Over)
3099    }
3100}
3101
3102/// Body region a wearable item occupies — at most one item equipped per slot.
3103/// Armor, cloak, jewelry, and carriers (`Back` backpack, `Waist` belt). Hands
3104/// (mainhand / offhand) are separate combat sockets — not `BodySlot`.
3105#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)]
3106#[serde(rename_all = "snake_case")]
3107pub enum BodySlot {
3108    Head,
3109    /// Chest / torso armor. Serde alias `body` for older YAML / saves.
3110    #[serde(alias = "body")]
3111    Chest,
3112    /// Gauntlets / bracers / sleeves. Serde alias `arms` for older YAML / saves.
3113    #[serde(alias = "arms")]
3114    Forearms,
3115    Legs,
3116    Feet,
3117    Cloak,
3118    Back,
3119    Waist,
3120    Earrings,
3121    Necklace,
3122    Eyeglasses,
3123    /// Explicit rename: plain `snake_case` would be `ring_left1`.
3124    #[serde(rename = "ring_left_1", alias = "ring_left1")]
3125    RingLeft1,
3126    #[serde(rename = "ring_left_2", alias = "ring_left2")]
3127    RingLeft2,
3128    #[serde(rename = "ring_right_1", alias = "ring_right1")]
3129    RingRight1,
3130    #[serde(rename = "ring_right_2", alias = "ring_right2")]
3131    RingRight2,
3132}
3133
3134impl BodySlot {
3135    /// All worn slots in paperdoll / catalog order.
3136    pub const ALL: [BodySlot; 15] = [
3137        BodySlot::Head,
3138        BodySlot::Chest,
3139        BodySlot::Forearms,
3140        BodySlot::Legs,
3141        BodySlot::Feet,
3142        BodySlot::Cloak,
3143        BodySlot::Back,
3144        BodySlot::Waist,
3145        BodySlot::Earrings,
3146        BodySlot::Necklace,
3147        BodySlot::Eyeglasses,
3148        BodySlot::RingLeft1,
3149        BodySlot::RingLeft2,
3150        BodySlot::RingRight1,
3151        BodySlot::RingRight2,
3152    ];
3153
3154    pub fn as_str(self) -> &'static str {
3155        match self {
3156            BodySlot::Head => "head",
3157            BodySlot::Chest => "chest",
3158            BodySlot::Forearms => "forearms",
3159            BodySlot::Legs => "legs",
3160            BodySlot::Feet => "feet",
3161            BodySlot::Cloak => "cloak",
3162            BodySlot::Back => "back",
3163            BodySlot::Waist => "waist",
3164            BodySlot::Earrings => "earrings",
3165            BodySlot::Necklace => "necklace",
3166            BodySlot::Eyeglasses => "eyeglasses",
3167            BodySlot::RingLeft1 => "ring_left_1",
3168            BodySlot::RingLeft2 => "ring_left_2",
3169            BodySlot::RingRight1 => "ring_right_1",
3170            BodySlot::RingRight2 => "ring_right_2",
3171        }
3172    }
3173}
3174
3175/// Where an item lives for `MoveItem` / open-container UX.
3176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3177#[serde(rename_all = "snake_case")]
3178pub enum InventoryLocation {
3179    /// Loose on-person inventory (not inside a worn/placed container).
3180    Root,
3181    /// Inside a worn item (backpack contents, or a pouch clipped onto a worn belt).
3182    Worn { slot: BodySlot },
3183    /// Inside a world-placed container.
3184    Placed { container_id: String },
3185    /// Virtual key ring — only `container_key` items; zero carry mass; persists with combat profile.
3186    Keychain,
3187    /// Virtual whisper pouch — only `whisper_stone` items; zero carry mass.
3188    WhisperPouch,
3189}
3190
3191/// AOI view of a placeable chest on the map.
3192#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3193pub struct PlacedContainerView {
3194    pub id: String,
3195    pub template_id: String,
3196    pub display_name: String,
3197    pub x: f32,
3198    pub y: f32,
3199    pub z: f32,
3200    pub locked: bool,
3201    /// Observer can open (unlocked, or holds matching key).
3202    #[serde(default)]
3203    pub accessible: bool,
3204    #[serde(default)]
3205    pub owner_character_id: Option<Uuid>,
3206    /// Nested contents when `accessible` (empty when locked without key).
3207    #[serde(default)]
3208    pub contents: Vec<ItemStack>,
3209    /// Lock id for matching `container_key` props (`opens_lock_id`).
3210    #[serde(default)]
3211    pub lock_id: Option<String>,
3212    /// Internal storage capacity (liters) from item catalog.
3213    #[serde(default)]
3214    pub capacity_volume: Option<f32>,
3215    /// Container instance id (for MoveItem parent targeting).
3216    #[serde(default)]
3217    pub item_instance_id: Option<Uuid>,
3218    /// Optional gfx tile id from the item template.
3219    #[serde(default)]
3220    pub tile_id: Option<String>,
3221    /// Hired-worker slots when this is placed lodging (`category: lodging`).
3222    #[serde(default)]
3223    pub worker_lodging_capacity: Option<u32>,
3224    /// From item template — blocks movement and autopath when placed.
3225    #[serde(default)]
3226    pub blocking: bool,
3227    /// Collision radius (m) for pathfinding when `blocking`.
3228    #[serde(default)]
3229    pub blocking_radius_m: f32,
3230    /// Interior space when placed indoors (`None` = outdoors). Postcard always
3231    /// serializes optionals so decode stays aligned — bump `PROTOCOL_VERSION`.
3232    #[serde(default)]
3233    pub building_id: Option<String>,
3234}
3235
3236#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3237pub struct BlueprintIngredientView {
3238    pub template_id: String,
3239    pub quantity: u32,
3240    /// Always serialize — `true` is not bool::default() so postcard keeps it; explicit for clarity.
3241    pub consumed: bool,
3242    /// Catalog display name for UI (never show bare template_id when this is set).
3243    #[serde(default)]
3244    pub display_name: String,
3245}
3246
3247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3248pub struct ToolRequirementView {
3249    pub item: String,
3250    /// Always serialize — postcard omits `false` by default, which breaks roundtrip without explicit value.
3251    pub consumed: bool,
3252    /// Catalog display name for UI.
3253    #[serde(default)]
3254    pub display_name: String,
3255}
3256
3257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3258pub struct SkillRequirementView {
3259    pub skill: String,
3260    pub level: u32,
3261}
3262
3263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3264pub struct BlueprintView {
3265    pub id: String,
3266    pub label: String,
3267    pub output: String,
3268    pub output_qty: u32,
3269    pub craft_ticks: u32,
3270    pub inputs: Vec<BlueprintIngredientView>,
3271    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
3272    #[serde(default)]
3273    pub station: Option<String>,
3274    #[serde(default)]
3275    pub category: Option<String>,
3276    #[serde(default)]
3277    pub required_tools: Vec<ToolRequirementView>,
3278    #[serde(default)]
3279    pub skill: Option<SkillRequirementView>,
3280    #[serde(default)]
3281    pub failure_chance: f32,
3282    /// Copper cost for the employer to teach this recipe to a hired worker.
3283    #[serde(default)]
3284    pub worker_train_copper: u64,
3285    /// Catalog display name for `output` (UI must prefer this over the raw template id).
3286    #[serde(default)]
3287    pub output_display_name: String,
3288    /// Derived craft dependency depth (1 = only raw inputs). Tools do not deepen this.
3289    #[serde(default)]
3290    pub craft_tier: u32,
3291}
3292
3293/// Per-kind pathfinding params replicated so clients share server content speeds.
3294#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3295pub struct TerrainKindNavView {
3296    pub kind: TerrainKindView,
3297    #[serde(default = "default_move_speed_mult_one")]
3298    pub move_speed_mult: f32,
3299    #[serde(default)]
3300    pub impassable: bool,
3301}
3302
3303fn default_move_speed_mult_one() -> f32 {
3304    1.0
3305}
3306
3307/// Terrain overlay from segment YAML (`terrain_zones`).
3308#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3309#[serde(rename_all = "snake_case")]
3310pub enum TerrainKindView {
3311    #[default]
3312    Grass,
3313    Dirt,
3314    Tilled,
3315    Desert,
3316    Hill,
3317    Bog,
3318    Beach,
3319    ShallowWater,
3320    DeepWater,
3321    Trail,
3322    Road,
3323    Rock,
3324}
3325
3326#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3327pub struct TerrainZoneView {
3328    pub id: String,
3329    pub x0: f32,
3330    pub y0: f32,
3331    pub x1: f32,
3332    pub y1: f32,
3333    #[serde(default)]
3334    pub kind: TerrainKindView,
3335    /// Ground elevation at this zone (m).
3336    #[serde(default)]
3337    pub elevation: f32,
3338    /// Optional map glyph override (single character); falls back to terrain kind catalog.
3339    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
3340    #[serde(default)]
3341    pub glyph: Option<String>,
3342    /// Optional color (`#RRGGBB` or ratatui name); falls back to kind / elevation tint.
3343    #[serde(default)]
3344    pub color: Option<String>,
3345    /// Optional gfx tile id (`terrain.grass`, …) — presentation only.
3346    #[serde(default)]
3347    pub tile_id: Option<String>,
3348    /// Overlap priority — higher wins (`segment.terrain_zones`).
3349    #[serde(default)]
3350    pub z_order: i32,
3351    /// In-progress till/plant channel on this cell (sim ticks).
3352    #[serde(default)]
3353    pub channel_start_tick: Option<Tick>,
3354    #[serde(default)]
3355    pub channel_end_tick: Option<Tick>,
3356}
3357
3358/// Axis-aligned rect used by property / tax zone views.
3359#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3360pub struct ZoneRectView {
3361    pub x0: f32,
3362    pub y0: f32,
3363    pub x1: f32,
3364    pub y1: f32,
3365}
3366
3367/// Crown property zone (claimable land) — plan 40.
3368#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3369pub struct PropertyZoneView {
3370    pub id: String,
3371    /// Designer label when present; clients prefer this over id.
3372    #[serde(default)]
3373    pub label: Option<String>,
3374    pub rects: Vec<ZoneRectView>,
3375    #[serde(default)]
3376    pub z_order: i32,
3377    pub crown_price_copper: u64,
3378    pub upkeep_copper_per_day: u64,
3379    #[serde(default)]
3380    pub max_area_m2: Option<f32>,
3381    #[serde(default)]
3382    pub owner_tax_discount_bps: u32,
3383}
3384
3385/// Tax overlay for claim premium preview.
3386#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3387pub struct TaxZoneView {
3388    pub id: String,
3389    #[serde(default)]
3390    pub label: Option<String>,
3391    pub rects: Vec<ZoneRectView>,
3392    #[serde(default)]
3393    pub z_order: i32,
3394    pub rate_bps: u32,
3395    #[serde(default)]
3396    pub flat_copper: u64,
3397    /// Market-hall sales tax in basis points of sale total.
3398    #[serde(default)]
3399    pub market_sales_tax_bps: u32,
3400    /// Optional flat copper per market-hall purchase.
3401    #[serde(default)]
3402    pub market_sales_flat_copper: u32,
3403}
3404
3405/// Town / security / PvP boundary overlay (plan 43).
3406#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3407pub struct BoundaryZoneView {
3408    pub id: String,
3409    #[serde(default)]
3410    pub label: Option<String>,
3411    pub rects: Vec<ZoneRectView>,
3412    #[serde(default)]
3413    pub z_order: i32,
3414    #[serde(default, skip_serializing_if = "Option::is_none")]
3415    pub jurisdiction_id: Option<String>,
3416    #[serde(default = "default_true")]
3417    pub worker_logistics: bool,
3418    #[serde(default)]
3419    pub security_tier: String,
3420    #[serde(default)]
3421    pub pvp_mode: String,
3422    #[serde(default = "default_true")]
3423    pub crime_enabled: bool,
3424    #[serde(default)]
3425    pub guard_response: bool,
3426    /// When true, placeable props do not block movement inside this zone.
3427    #[serde(default)]
3428    pub pass_through_props: bool,
3429    /// `fov_los` (default) or `full_aoi`.
3430    #[serde(default)]
3431    pub presence_mode: String,
3432}
3433
3434/// Random encounter overlay (plan 54).
3435#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3436pub struct EncounterZoneView {
3437    pub id: String,
3438    #[serde(default)]
3439    pub label: Option<String>,
3440    pub rects: Vec<ZoneRectView>,
3441    #[serde(default)]
3442    pub z_order: i32,
3443}
3444
3445fn default_true() -> bool {
3446    true
3447}
3448
3449/// Growth / fertility overlay — faster respawn & farm tuning (plan 37 / 40).
3450#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3451pub struct GrowthZoneView {
3452    pub id: String,
3453    #[serde(default)]
3454    pub label: Option<String>,
3455    pub rects: Vec<ZoneRectView>,
3456    #[serde(default)]
3457    pub z_order: i32,
3458    #[serde(default = "default_one_f32")]
3459    pub fertility: f32,
3460}
3461
3462fn default_one_f32() -> f32 {
3463    1.0
3464}
3465
3466/// Climate / biome overlay (plan 38).
3467#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3468pub struct BiomeZoneView {
3469    pub id: String,
3470    #[serde(default)]
3471    pub label: Option<String>,
3472    pub rects: Vec<ZoneRectView>,
3473    #[serde(default)]
3474    pub z_order: i32,
3475    pub biome_id: String,
3476}
3477
3478/// Named tenant on a property plot (farm access + tax discount).
3479#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3480pub struct FarmGrantView {
3481    pub character_id: Uuid,
3482    /// Display name when known (AOI / online); empty if offline-only id.
3483    #[serde(default)]
3484    pub character_label: String,
3485    pub tax_discount_bps: u32,
3486}
3487
3488/// Claimed plot visible to clients (plan 40).
3489#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3490pub struct PropertyPlotView {
3491    pub plot_id: Uuid,
3492    pub property_zone_id: String,
3493    #[serde(default)]
3494    pub zone_label: Option<String>,
3495    pub deed_instance_id: Uuid,
3496    pub x0: f32,
3497    pub y0: f32,
3498    pub x1: f32,
3499    pub y1: f32,
3500    pub upkeep_copper_per_day: u64,
3501    pub arrears_days: u32,
3502    /// Observer currently holds this plot's deed.
3503    #[serde(default)]
3504    pub is_mine: bool,
3505    /// Observer may cultivate/plant/harvest on this plot (deed, owner, public, or grant).
3506    #[serde(default)]
3507    pub may_farm: bool,
3508    /// Book value when known (purchase / last private sale).
3509    #[serde(default)]
3510    pub purchase_basis_copper: u64,
3511    #[serde(default)]
3512    pub farm_public: bool,
3513    #[serde(default)]
3514    pub public_tax_discount_bps: u32,
3515    #[serde(default)]
3516    pub farm_allow: Vec<FarmGrantView>,
3517    /// Owner character when known.
3518    #[serde(default)]
3519    pub owner_character_id: Option<Uuid>,
3520    #[serde(default)]
3521    pub owner_label: Option<String>,
3522    /// Player building on this plot, if any.
3523    #[serde(default)]
3524    pub building_id: Option<String>,
3525    /// Immutable short code derived from `plot_id` (e.g. `xyz1234a`).
3526    #[serde(default)]
3527    pub plot_code: String,
3528    /// Owner-chosen label (defaults to `plot_code`).
3529    #[serde(default)]
3530    pub label: String,
3531}
3532
3533/// Subset of server settings for client-side claim quotes.
3534#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3535pub struct PropertyPlotSettingsView {
3536    pub min_plot_area_m2: f32,
3537    pub tax_premium_weight: f32,
3538    pub sellback_bps: u32,
3539}
3540
3541/// Walkable platform at a fixed z (`segment.z_platforms`).
3542#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3543pub struct ZPlatformView {
3544    pub id: String,
3545    pub z: f32,
3546    pub x0: f32,
3547    pub y0: f32,
3548    pub x1: f32,
3549    pub y1: f32,
3550}
3551
3552/// Stairs / ramp linking two z bands (`segment.z_transitions`).
3553#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3554pub struct ZTransitionView {
3555    pub id: String,
3556    pub z_from: f32,
3557    pub z_to: f32,
3558    pub x0: f32,
3559    pub y0: f32,
3560    pub x1: f32,
3561    pub y1: f32,
3562}
3563
3564#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3565pub struct BuildingView {
3566    pub id: String,
3567    pub label: String,
3568    pub x: f32,
3569    pub y: f32,
3570    pub width_m: f32,
3571    pub depth_m: f32,
3572    #[serde(default)]
3573    pub interior_blueprint: Option<String>,
3574    #[serde(default)]
3575    pub tags: Vec<String>,
3576    /// Boundary zones this market hall participates in (`plans/10`).
3577    #[serde(default)]
3578    pub market_boundary_zone_ids: Vec<String>,
3579    /// Escrow capacity when tagged `market`. None → server default.
3580    #[serde(default)]
3581    pub market_max_volume: Option<f32>,
3582    /// Wall art set id (`schemas/gfx-sprites.schema.json` `wall_set`). `None` → `classic_stone`
3583    /// (legacy `building.wall_h`/`wall_v`/`wall_corner`/`door_open`/`door_closed` sprites).
3584    #[serde(default)]
3585    pub wall_set: Option<String>,
3586    /// Roof art set id (forge `roof_set` output_stem). `None` → `classic_stone`.
3587    #[serde(default)]
3588    pub roof_set: Option<String>,
3589}
3590
3591/// Default wall/roof set id when a building doesn't specify one — keeps existing
3592/// content rendering pixel-identical (`plans/47` Workstream C).
3593pub const DEFAULT_BUILDING_ART_SET: &str = "classic_stone";
3594
3595impl BuildingView {
3596    pub fn effective_wall_set(&self) -> &str {
3597        self.wall_set
3598            .as_deref()
3599            .filter(|s| !s.is_empty())
3600            .unwrap_or(DEFAULT_BUILDING_ART_SET)
3601    }
3602
3603    pub fn effective_roof_set(&self) -> &str {
3604        self.roof_set
3605            .as_deref()
3606            .filter(|s| !s.is_empty())
3607            .unwrap_or(DEFAULT_BUILDING_ART_SET)
3608    }
3609}
3610
3611#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3612pub struct DoorView {
3613    pub id: String,
3614    pub building_id: String,
3615    pub x: f32,
3616    pub y: f32,
3617    #[serde(default)]
3618    pub open: bool,
3619    #[serde(default)]
3620    pub portal: Option<String>,
3621    /// Exterior door locked: player-house key lock, or map-building door hours closed.
3622    /// Clients draw the padlock badge when `locked && !open`.
3623    #[serde(default)]
3624    pub locked: bool,
3625    /// Observer can open/close/enter. False when key-locked or outside door hours.
3626    #[serde(default = "default_door_accessible")]
3627    pub accessible: bool,
3628    #[serde(default)]
3629    pub lock_id: Option<Uuid>,
3630}
3631
3632fn default_door_accessible() -> bool {
3633    true
3634}
3635
3636/// Client → server interior room layout (confirm edit).
3637#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3638pub struct InteriorRoomEdit {
3639    pub id: String,
3640    pub label: String,
3641    pub x0: f32,
3642    pub y0: f32,
3643    pub x1: f32,
3644    pub y1: f32,
3645}
3646
3647#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3648pub struct InteriorRoomDoorEdit {
3649    pub id: String,
3650    pub room_a: String,
3651    pub room_b: String,
3652    pub x: f32,
3653    pub y: f32,
3654}
3655
3656#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3657pub struct InteriorRoomView {
3658    pub id: String,
3659    pub label: String,
3660    pub floor: i32,
3661    pub x0: f32,
3662    pub y0: f32,
3663    pub x1: f32,
3664    pub y1: f32,
3665    #[serde(default)]
3666    pub floor_color: Option<String>,
3667    #[serde(default)]
3668    pub floor_glyph: Option<String>,
3669}
3670
3671#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3672pub struct InteriorDoorView {
3673    pub id: String,
3674    pub room_a: String,
3675    pub room_b: String,
3676    pub x: f32,
3677    pub y: f32,
3678    pub kind: String,
3679    #[serde(default)]
3680    pub x_a: Option<f32>,
3681    #[serde(default)]
3682    pub y_a: Option<f32>,
3683    #[serde(default)]
3684    pub x_b: Option<f32>,
3685    #[serde(default)]
3686    pub y_b: Option<f32>,
3687}
3688
3689#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3690pub struct InteriorMapView {
3691    pub building_id: String,
3692    pub blueprint_id: String,
3693    pub background_color: String,
3694    #[serde(default)]
3695    pub default_floor_color: Option<String>,
3696    #[serde(default = "default_floor_height_view")]
3697    pub floor_height_m: f32,
3698    /// Walkable platforms per floor (interior z-bands).
3699    #[serde(default)]
3700    pub z_platforms: Vec<ZPlatformView>,
3701    #[serde(default)]
3702    pub z_transitions: Vec<ZTransitionView>,
3703    pub rooms: Vec<InteriorRoomView>,
3704    #[serde(default)]
3705    pub room_doors: Vec<InteriorDoorView>,
3706}
3707
3708fn default_floor_height_view() -> f32 {
3709    3.0
3710}
3711
3712#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3713pub struct NpcView {
3714    pub id: String,
3715    pub label: String,
3716    pub role: String,
3717    pub x: f32,
3718    pub y: f32,
3719    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
3720    #[serde(default)]
3721    pub building_id: Option<String>,
3722    /// Authoritative sim entity for wildlife / combat targets.
3723    #[serde(default)]
3724    pub entity_id: Option<EntityId>,
3725    #[serde(default)]
3726    pub life_state: Option<LifeState>,
3727    #[serde(default)]
3728    pub hp_pct: Option<f32>,
3729    /// True when this NPC has buy/sell/teach offers (`npc_has_market`).
3730    #[serde(default)]
3731    pub can_trade: bool,
3732    /// Exact item template IDs this NPC buys, for worker-route sell pickers.
3733    #[serde(default)]
3734    pub buy_templates: Vec<String>,
3735    /// Gfx sprite sheet id (`assets/gfx/sprites/`). Client falls back to `npc.{id}` / `npc.{role}`.
3736    #[serde(default)]
3737    pub tile_id: Option<String>,
3738    /// Wildlife FSM state (`idle`, `chase`, `combat`, …) when behavior-driven (debug).
3739    #[serde(default)]
3740    pub behavior_state: Option<String>,
3741    /// Canonical gfx presentation key (`combat`, `pursue`, `walking`, `talking`, …).
3742    #[serde(default)]
3743    pub presentation_state: Option<String>,
3744    /// Resolved gfx sprite mode for `tile_id` (server-computed).
3745    #[serde(default)]
3746    pub sprite_mode: Option<String>,
3747    /// Paperdoll skin id (`assets/paperdoll/skins/`). Client prefers this over `tile_id` when baked.
3748    #[serde(default)]
3749    pub paperdoll_ref: Option<String>,
3750    /// World draw size in map cells (from paperdoll skin `draw_scale`; 1.0 = one cell).
3751    #[serde(default = "default_draw_scale")]
3752    pub draw_scale: f32,
3753    /// Authoritative body yaw (radians) for wildlife LoS debug (F7).
3754    #[serde(default)]
3755    pub yaw: Option<f32>,
3756    /// Active visual acquire cone (degrees); 360 when engaged.
3757    #[serde(default)]
3758    pub perception_fov_deg: Option<f32>,
3759    /// Sight radius used for acquire/tracking (meters).
3760    #[serde(default)]
3761    pub perception_sight_m: Option<f32>,
3762    /// Hearing radius from behavior (meters); omitted when deaf.
3763    #[serde(default)]
3764    pub perception_hear_m: Option<f32>,
3765    /// Per-observer quest verbs for this NPC (offer / talk / give). Empty for wildlife.
3766    #[serde(default)]
3767    pub quest_verbs: Vec<NpcQuestVerb>,
3768}
3769
3770/// Quest-tied NPC menu verb. `kind` is `offer`, `talk`, or `give`.
3771#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
3772pub struct NpcQuestVerb {
3773    pub quest_id: String,
3774    /// Player-facing verb (quest title or authored `talk_verb`).
3775    pub label: String,
3776    pub kind: String,
3777}
3778
3779impl NpcQuestVerb {
3780    pub const KIND_OFFER: &'static str = "offer";
3781    pub const KIND_TALK: &'static str = "talk";
3782    pub const KIND_GIVE: &'static str = "give";
3783}
3784
3785#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3786pub struct UseResult {
3787    pub template_id: String,
3788    pub hunger_restored: f32,
3789    pub thirst_restored: f32,
3790    #[serde(default)]
3791    pub health_restored: f32,
3792    #[serde(default)]
3793    pub mana_restored: f32,
3794    #[serde(default)]
3795    pub cleared_dot_ids: Vec<String>,
3796}
3797
3798#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3799pub struct CraftResult {
3800    pub blueprint_id: String,
3801    pub outputs: Vec<ItemStack>,
3802    pub consumed: Vec<ItemStack>,
3803    /// 1-based index within the submitted batch (1 when not batching).
3804    #[serde(default = "default_one")]
3805    pub batch_index: u32,
3806    /// Total crafts requested in this batch (1 when not batching).
3807    #[serde(default = "default_one")]
3808    pub batch_total: u32,
3809}
3810
3811fn default_one() -> u32 {
3812    1
3813}
3814
3815#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3816pub struct DeathNotice {
3817    pub entity_id: EntityId,
3818    pub respawn_x: f32,
3819    pub respawn_y: f32,
3820    pub message: String,
3821}
3822
3823#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3824pub struct InteractionNotice {
3825    pub target_id: String,
3826    pub message: String,
3827    #[serde(default)]
3828    pub coins_delta: i32,
3829    #[serde(default)]
3830    pub inventory_delta: Vec<ItemStack>,
3831}
3832
3833#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3834#[serde(rename_all = "snake_case")]
3835pub enum NpcTalkTrustFlag {
3836    Stranger,
3837    Acquainted,
3838    Trusted,
3839}
3840
3841#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
3842#[serde(rename_all = "snake_case")]
3843pub enum NpcTalkDepth {
3844    #[default]
3845    Full,
3846    Brief,
3847    Unavailable,
3848}
3849
3850#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3851pub struct NpcTalkOpened {
3852    pub npc_id: String,
3853    pub npc_label: String,
3854    pub greeting: String,
3855    pub trust_flag: NpcTalkTrustFlag,
3856    #[serde(default)]
3857    pub talk_depth: NpcTalkDepth,
3858    #[serde(default = "default_true")]
3859    pub trade_allowed: bool,
3860    /// Keyword/topic hints for deterministic chat (e.g. "Trade", "Goblins").
3861    #[serde(default)]
3862    pub suggested_topics: Vec<String>,
3863}
3864
3865#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3866pub struct NpcTalkPending {
3867    pub npc_id: String,
3868}
3869
3870#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3871pub struct NpcTalkReply {
3872    pub npc_id: String,
3873    pub line: String,
3874    pub trust_flag: NpcTalkTrustFlag,
3875    #[serde(default)]
3876    pub wind_down: bool,
3877    #[serde(default)]
3878    pub trade_disabled: bool,
3879}
3880
3881#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3882pub struct NpcTalkClosed {
3883    pub npc_id: String,
3884}
3885
3886#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3887pub struct NpcTalkError {
3888    pub npc_id: String,
3889    pub reason: String,
3890}
3891
3892#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3893#[serde(rename_all = "snake_case")]
3894pub enum QuestStatusView {
3895    Available,
3896    Active,
3897    Completed,
3898}
3899
3900#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3901pub struct QuestObjectiveProgress {
3902    pub label: String,
3903    pub current: u32,
3904    pub required: u32,
3905    pub done: bool,
3906    /// `talk_npc` / `kill_npc` / `harvest_item` / `give_item` / `interact` /
3907    /// `craft` / `buy_item` / `sell_item` / `enter_building` / `use_item` /
3908    /// `pickup_item` / `learn_blueprint`.
3909    #[serde(default)]
3910    pub kind: String,
3911    #[serde(default)]
3912    pub npc_ref: Option<String>,
3913    #[serde(default)]
3914    pub item_template: Option<String>,
3915    #[serde(default)]
3916    pub blueprint_id: Option<String>,
3917    #[serde(default)]
3918    pub building_id: Option<String>,
3919}
3920
3921/// One item granted by a quest step or final completion reward.
3922#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
3923pub struct QuestRewardItemView {
3924    pub template_id: String,
3925    /// Friendly catalog name for HUD / TUI (never show raw template id alone).
3926    #[serde(default)]
3927    pub display_name: String,
3928    pub quantity: u32,
3929}
3930
3931/// Coins + items granted when a step (or the quest) completes.
3932#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
3933pub struct QuestRewardView {
3934    #[serde(default)]
3935    pub coins: u32,
3936    #[serde(default)]
3937    pub items: Vec<QuestRewardItemView>,
3938}
3939
3940impl QuestRewardView {
3941    pub fn is_empty(&self) -> bool {
3942        self.coins == 0 && self.items.is_empty()
3943    }
3944}
3945
3946#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3947#[serde(rename_all = "snake_case")]
3948pub enum QuestStepStatusView {
3949    #[default]
3950    Pending,
3951    Current,
3952    Done,
3953}
3954
3955/// Authored step summary for quest log / journal UI.
3956#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3957pub struct QuestStepView {
3958    pub id: String,
3959    pub title: String,
3960    #[serde(default)]
3961    pub status: QuestStepStatusView,
3962    #[serde(default)]
3963    pub reward: QuestRewardView,
3964}
3965
3966#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3967pub struct QuestLogEntry {
3968    pub quest_id: String,
3969    pub title: String,
3970    pub description: String,
3971    pub status: QuestStatusView,
3972    #[serde(default)]
3973    pub current_step_id: Option<String>,
3974    #[serde(default)]
3975    pub current_step_title: String,
3976    #[serde(default)]
3977    pub current_step_index: u32,
3978    #[serde(default)]
3979    pub objectives: Vec<QuestObjectiveProgress>,
3980    /// Reward granted when the current step completes (active quests).
3981    #[serde(default)]
3982    pub current_step_reward: QuestRewardView,
3983    /// Reward on the final step (quest completion prize).
3984    #[serde(default)]
3985    pub completion_reward: QuestRewardView,
3986    /// Full step list with per-step rewards for the journal.
3987    #[serde(default)]
3988    pub steps: Vec<QuestStepView>,
3989    #[serde(default)]
3990    pub is_tracked: bool,
3991    #[serde(default)]
3992    pub can_withdraw: bool,
3993}
3994
3995#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3996pub struct InteractableView {
3997    pub id: String,
3998    pub kind: String,
3999    pub label: String,
4000    pub x: f32,
4001    pub y: f32,
4002    pub z: f32,
4003    #[serde(default)]
4004    pub board_id: Option<String>,
4005}
4006
4007#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4008pub struct QuestOffer {
4009    pub quest_id: String,
4010    pub title: String,
4011    pub description: String,
4012    #[serde(default)]
4013    pub step_count: u32,
4014}
4015
4016#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4017pub struct QuestCatalogEntry {
4018    pub quest_id: String,
4019    pub title: String,
4020    pub description: String,
4021    pub step_count: u32,
4022    #[serde(default)]
4023    pub board_ids: Vec<String>,
4024}
4025
4026#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4027pub struct QuestCatalogUpdated {
4028    pub revision: u64,
4029    pub game_day: String,
4030    #[serde(default)]
4031    pub accepted: Vec<QuestCatalogEntry>,
4032    #[serde(default)]
4033    pub retired: Vec<String>,
4034}
4035
4036#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4037pub struct QuestNotice {
4038    pub quest_id: String,
4039    pub title: String,
4040    pub message: String,
4041}
4042
4043#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4044#[serde(rename_all = "snake_case")]
4045pub enum ShopOfferKind {
4046    Item,
4047    Blueprint,
4048}
4049
4050#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4051pub struct ShopOffer {
4052    pub offer_id: String,
4053    pub kind: ShopOfferKind,
4054    pub label: String,
4055    #[serde(default)]
4056    pub template_id: Option<String>,
4057    #[serde(default)]
4058    pub blueprint_id: Option<String>,
4059    pub price_copper: u32,
4060    #[serde(default)]
4061    pub affordable: bool,
4062    #[serde(default)]
4063    pub already_owned: bool,
4064}
4065
4066#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4067pub struct ShopBuyLine {
4068    pub template_id: String,
4069    pub label: String,
4070    pub quantity: u32,
4071    pub price_copper: u32,
4072}
4073
4074/// Bank teller interaction panel (`plans/08` §8).
4075#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4076pub struct BankPanel {
4077    pub npc_id: String,
4078    pub npc_label: String,
4079    pub bank_balance_copper: u64,
4080    pub on_person_copper: u64,
4081    /// Copper still clearing to other accounts (debited, not yet credited).
4082    #[serde(default)]
4083    pub pending_outgoing_copper: u64,
4084    #[serde(default)]
4085    pub transfer_fee_bps: u32,
4086    #[serde(default)]
4087    pub transfer_clear_ticks: u64,
4088}
4089
4090/// Town storage manager panel (`plans/08` §7).
4091#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4092pub struct StoragePanel {
4093    pub npc_id: String,
4094    pub npc_label: String,
4095    pub building_id: String,
4096    pub building_label: String,
4097    pub used_volume: f32,
4098    pub max_volume: f32,
4099    #[serde(default)]
4100    pub contents: Vec<ItemStack>,
4101    /// Other storage buildings that can receive a ship (id, label, distance_m, fee_copper, travel_ticks).
4102    #[serde(default)]
4103    pub ship_destinations: Vec<StorageShipDest>,
4104}
4105
4106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4107pub struct StorageShipDest {
4108    pub building_id: String,
4109    pub label: String,
4110    pub distance_m: f32,
4111    pub fee_copper: u64,
4112    pub travel_ticks: u64,
4113}
4114
4115/// Source or destination for market-hall goods movement
4116/// (`plans/10-economy-and-markets.md` §4.3/§4.4).
4117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4118pub enum GoodsLocation {
4119    /// On-person inventory (root, non-nested).
4120    Person,
4121    /// A town storage vault at a building whose jurisdiction intersects the
4122    /// listing hall's `market_boundary_zone_ids`.
4123    TownStorage { building_id: String },
4124}
4125
4126/// One escrowed market-hall listing, as seen from a browsing hall
4127/// (`plans/10-economy-and-markets.md` §4.2).
4128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4129pub struct MarketListingView {
4130    pub listing_id: Uuid,
4131    pub seller_character_id: Uuid,
4132    /// Seller display name (`prefer-labels-over-ids`).
4133    pub seller_label: String,
4134    pub hall_building_id: String,
4135    pub hall_label: String,
4136    pub template_id: String,
4137    pub display_name: String,
4138    /// Item catalog category (`resource`, `weapon`, …) for client filters.
4139    #[serde(default)]
4140    pub category: String,
4141    pub quantity: u32,
4142    pub unit_price_copper: u64,
4143    /// Total for the full remaining quantity (`quantity * unit_price_copper`).
4144    pub line_total_copper: u64,
4145    /// Dump-queue listing — players cannot buy (`plans/53`).
4146    #[serde(default)]
4147    pub npc_price: bool,
4148    /// Estimated net copper per unit for NPC-price rows (default buyer rates).
4149    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
4150    #[serde(default)]
4151    pub npc_dump_unit_copper: Option<u32>,
4152    /// True when the viewer is the seller — reprice/delist allowed.
4153    pub mine: bool,
4154}
4155
4156/// Town storage vault eligible as a list/buy/delist goods source for a market hall.
4157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4158pub struct MarketListVault {
4159    pub building_id: String,
4160    /// Designer label (`prefer-labels-over-ids`).
4161    pub building_label: String,
4162    #[serde(default)]
4163    pub contents: Vec<ItemStack>,
4164}
4165
4166/// Market hall clerk panel — browse (zone-linked halls), list / reprice / delist,
4167/// buy confirm (`plans/10-economy-and-markets.md` §4, §10).
4168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4169pub struct MarketPanel {
4170    pub npc_id: String,
4171    pub npc_label: String,
4172    pub building_id: String,
4173    pub building_label: String,
4174    /// Escrow volume used at *this* hall only (cap is per-hall, `market_max_volume`).
4175    pub used_volume: f32,
4176    pub max_volume: f32,
4177    /// Listings at this hall plus any zone-linked halls (`market_boundary_zone_ids`
4178    /// intersection) — the shared browse book (§4.2).
4179    #[serde(default)]
4180    pub listings: Vec<MarketListingView>,
4181    /// Crown sales tax at the tax zone covering this hall (§6).
4182    #[serde(default)]
4183    pub tax_bps: u32,
4184    #[serde(default)]
4185    pub tax_flat_copper: u32,
4186    /// Zone-eligible town storage vaults the caller can list from (§4.3).
4187    #[serde(default)]
4188    pub list_vaults: Vec<MarketListVault>,
4189}
4190
4191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4192pub struct ShopCatalog {
4193    pub npc_id: String,
4194    pub npc_label: String,
4195    #[serde(default)]
4196    pub sells: Vec<ShopOffer>,
4197    #[serde(default)]
4198    pub buys: Vec<ShopBuyLine>,
4199}
4200
4201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4202pub struct HarvestResult {
4203    pub node_id: String,
4204    /// Stack quantity granted (not one-node-one-instance).
4205    pub quantity: u32,
4206    pub item_template: String,
4207    /// Optional DB row id when persisted to control plane.
4208    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
4209    #[serde(default)]
4210    pub item_instance_id: Option<Uuid>,
4211}
4212
4213/// Every on-wire payload is wrapped for versioning and codec uniformity.
4214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4215pub struct Envelope<T> {
4216    pub protocol_version: u16,
4217    pub payload: T,
4218}
4219
4220impl<T> Envelope<T> {
4221    pub fn new(payload: T) -> Self {
4222        Self {
4223            protocol_version: crate::PROTOCOL_VERSION,
4224            payload,
4225        }
4226    }
4227}
4228
4229/// Session handshake after transport connect.
4230#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4231pub struct Hello {
4232    pub client_name: String,
4233    pub protocol_version: u16,
4234    #[serde(default)]
4235    pub auth: AuthCredential,
4236    /// Required for session auth; embedded in `ApiToken` variant otherwise.
4237    #[serde(default)]
4238    pub character_id: Option<Uuid>,
4239}
4240
4241/// How the client authenticates to the game gateway.
4242/// Uses default serde enum encoding (postcard-compatible; not internally tagged).
4243#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4244#[serde(rename_all = "snake_case")]
4245pub enum AuthCredential {
4246    DevLocal,
4247    Session { token: String },
4248    ApiToken { token: String, character_id: Uuid },
4249}
4250
4251impl Default for AuthCredential {
4252    fn default() -> Self {
4253        Self::DevLocal
4254    }
4255}
4256
4257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4258pub struct Welcome {
4259    pub session_id: SessionId,
4260    pub entity_id: EntityId,
4261    pub snapshot: Snapshot,
4262}
4263
4264#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4265pub enum ServerMessage {
4266    Welcome(Welcome),
4267    /// Static world layers refreshed (blueprints, catalog, map, terrain) — no client restart.
4268    ContentUpdated(Snapshot),
4269    Tick(TickDelta),
4270    IntentAck {
4271        entity_id: EntityId,
4272        seq: Seq,
4273        tick: Tick,
4274    },
4275    Chat(ChatMessage),
4276    HarvestResult(HarvestResult),
4277    UseResult(UseResult),
4278    CraftResult(CraftResult),
4279    Death(DeathNotice),
4280    Interaction(InteractionNotice),
4281    ShopOpened(ShopCatalog),
4282    NpcTalkOpened(NpcTalkOpened),
4283    NpcTalkPending(NpcTalkPending),
4284    NpcTalkReply(NpcTalkReply),
4285    NpcTalkClosed(NpcTalkClosed),
4286    NpcTalkError(NpcTalkError),
4287    QuestOffer(QuestOffer),
4288    QuestAccepted(QuestNotice),
4289    QuestWithdrawn(QuestNotice),
4290    QuestStepCompleted(QuestNotice),
4291    QuestCompleted(QuestNotice),
4292    QuestCatalogUpdated(QuestCatalogUpdated),
4293    /// Bank teller panel opened (`plans/08` §8).
4294    BankOpened(BankPanel),
4295    /// Town storage manager panel opened (`plans/08` §7).
4296    StorageOpened(StoragePanel),
4297    /// Market hall clerk panel opened or refreshed (`plans/10-economy-and-markets.md`).
4298    MarketOpened(MarketPanel),
4299    /// Player-to-player trade window opened or refreshed.
4300    TradeOpened(TradePanel),
4301    /// Trade ended (cancel, complete, or peer left range).
4302    TradeClosed {
4303        reason: String,
4304    },
4305    /// Hello accepted but play refused (character already online, etc.).
4306    /// Sent instead of Welcome; TCP closes afterward.
4307    ConnectRejected {
4308        reason: String,
4309    },
4310}
4311
4312/// Live player-to-player trade escrow view (both sides see the same presented buckets).
4313#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4314pub struct TradePanel {
4315    pub peer_entity_id: EntityId,
4316    pub peer_name: String,
4317    pub my_presented: Vec<ItemStack>,
4318    pub their_presented: Vec<ItemStack>,
4319    pub i_ready: bool,
4320    pub they_ready: bool,
4321    /// Predicted carry mass after accepting their presented items (and losing mine).
4322    pub my_mass_after: f32,
4323    pub my_mass_max: f32,
4324    pub my_encumbrance_after: EncumbranceState,
4325    /// True when completing would put the local player Over.
4326    pub overburden_warning: bool,
4327}
4328
4329#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4330pub enum ClientMessage {
4331    Hello(Hello),
4332    Intent(Intent),
4333    Disconnect,
4334}
4335
4336#[cfg(test)]
4337mod tests {
4338    use super::*;
4339
4340    #[test]
4341    fn pristine_vitals_state_yields_full_pools() {
4342        let attrs = PrimaryAttributes::default();
4343        let vitals = StoredVitalsState::default().apply_to(attrs);
4344        assert!(vitals.health > 0.0);
4345        assert_eq!(vitals.health, vitals.health_max);
4346        assert!((vitals.mana_max - 61.0).abs() < 0.01);
4347    }
4348
4349    #[test]
4350    fn humanize_snake_id_title_cases_parts() {
4351        assert_eq!(humanize_snake_id("heal_touch"), "Heal Touch");
4352        assert_eq!(humanize_snake_id("fireball"), "Fireball");
4353        assert_eq!(humanize_snake_id("cone_frost"), "Cone Frost");
4354    }
4355
4356    #[test]
4357    fn saved_vitals_scale_when_pool_max_increases() {
4358        let mut attrs = PrimaryAttributes::default();
4359        attrs.intelligence = 140;
4360        attrs.wisdom = 140;
4361        let saved = StoredVitalsState {
4362            health: 100.0,
4363            mana: 14.0,
4364            stamina: 100.0,
4365            ..StoredVitalsState::default()
4366        };
4367        let vitals = saved.apply_to(attrs);
4368        assert!(vitals.mana_max > 55.0);
4369        assert!(
4370            (vitals.mana - vitals.mana_max).abs() < 0.01,
4371            "full legacy mana bar migrates to full new bar"
4372        );
4373    }
4374
4375    #[test]
4376    fn empty_vitals_state_is_pristine() {
4377        let pristine = StoredVitalsState {
4378            health: 0.0,
4379            mana: 0.0,
4380            stamina: 0.0,
4381            hunger: 0.0,
4382            thirst: 0.0,
4383            coins: 0,
4384            deaths: 0,
4385            life_state: LifeState::Alive,
4386        };
4387        assert!(pristine.is_pristine());
4388        let vitals = pristine.apply_to(PrimaryAttributes::default());
4389        assert!(vitals.health > 0.0);
4390    }
4391
4392    #[test]
4393    fn stored_vitals_roundtrip_preserves_partial_pools() {
4394        let attrs = PrimaryAttributes::default();
4395        let mut live = PlayerVitals::from_attributes(attrs);
4396        live.health = 25.0;
4397        live.hunger = 77.0;
4398        live.deaths = 2;
4399        let stored = StoredVitalsState::from_live(&live);
4400        let restored = stored.apply_to(attrs);
4401        assert!(
4402            (restored.health - 25.0).abs() < 0.01,
4403            "partial HP below cap stays absolute"
4404        );
4405        assert_eq!(restored.hunger, 77.0);
4406        assert_eq!(restored.deaths, 2);
4407    }
4408
4409    #[test]
4410    fn skill_tiers_start_at_zero() {
4411        let skill = SkillProgress::default();
4412        assert_eq!(skill.level, 0);
4413        assert_eq!(skill.display_tier(), 0);
4414        let trained = SkillProgress {
4415            level: 250,
4416            last_trained_tick: 1,
4417        };
4418        assert_eq!(trained.display_tier(), 2);
4419    }
4420
4421    #[test]
4422    fn quest_server_messages_roundtrip_json() {
4423        use crate::codec::{Codec, PostcardCodec};
4424
4425        let offer = ServerMessage::QuestOffer(QuestOffer {
4426            quest_id: "ada_goblin_hunt".into(),
4427            title: "Goblin Trouble".into(),
4428            description: "Help Ada".into(),
4429            step_count: 3,
4430        });
4431        let notice = ServerMessage::QuestAccepted(QuestNotice {
4432            quest_id: "ada_goblin_hunt".into(),
4433            title: "Goblin Trouble".into(),
4434            message: "Quest accepted".into(),
4435        });
4436        for msg in [offer, notice] {
4437            let bytes = PostcardCodec.encode(&msg).unwrap();
4438            let decoded: ServerMessage = PostcardCodec.decode(&bytes).unwrap();
4439            assert_eq!(decoded, msg);
4440        }
4441    }
4442
4443    #[test]
4444    fn hotbar_consumable_binding_roundtrips() {
4445        let binding = hotbar_consumable_binding("vegetable_soup");
4446        assert_eq!(binding, "item:vegetable_soup");
4447        assert!(hotbar_binding_is_consumable(&binding));
4448        assert_eq!(
4449            hotbar_consumable_template(&binding),
4450            Some("vegetable_soup")
4451        );
4452        assert!(!hotbar_binding_is_consumable("fireball"));
4453        assert_eq!(hotbar_consumable_template("fireball"), None);
4454    }
4455}