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