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