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    /// Short "what you should do" line when `last_error` is a player-actionable
2508    /// plan or logistics issue (missing chest, empty lodging, etc.).
2509    #[serde(default)]
2510    pub issue_hint: Option<String>,
2511}
2512
2513fn default_worker_view_level() -> u32 {
2514    1
2515}
2516
2517/// Server → client AOI-filtered entity updates for one sim tick.
2518#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2519pub struct TickDelta {
2520    pub tick: Tick,
2521    pub entities: Vec<EntityState>,
2522    #[serde(default)]
2523    pub resource_nodes: Vec<ResourceNodeView>,
2524    #[serde(default)]
2525    pub buildings: Vec<BuildingView>,
2526    #[serde(default)]
2527    pub doors: Vec<DoorView>,
2528    #[serde(default)]
2529    pub npcs: Vec<NpcView>,
2530    /// Observer inventory stacks (template → qty).
2531    #[serde(default)]
2532    pub inventory: Vec<ItemStack>,
2533    #[serde(default)]
2534    pub blueprints: Vec<BlueprintView>,
2535    /// Wall/roof material packs for the B plot-build menu.
2536    #[serde(default)]
2537    pub building_materials: Vec<BuildingMaterialView>,
2538    #[serde(default)]
2539    pub world_clock: WorldClock,
2540    #[serde(default)]
2541    pub ground_drops: Vec<GroundDropView>,
2542    #[serde(default)]
2543    pub placed_containers: Vec<PlacedContainerView>,
2544    #[serde(default)]
2545    pub combat: Option<CombatHud>,
2546    #[serde(default)]
2547    pub interior_map: Option<InteriorMapView>,
2548    #[serde(default)]
2549    pub quest_log: Vec<QuestLogEntry>,
2550    #[serde(default)]
2551    pub hired_workers: Vec<HiredWorkerView>,
2552    #[serde(default)]
2553    pub interactables: Vec<InteractableView>,
2554    #[serde(default)]
2555    pub ledger: Option<PlayerLedgerView>,
2556    #[serde(default)]
2557    pub career: Option<PlayerCareerView>,
2558    /// Active combat footprints / hit markers in observer AOI (`plans/39`).
2559    #[serde(default)]
2560    pub combat_fx: Vec<CombatFx>,
2561    /// Claimed property plots near the observer (plan 40).
2562    #[serde(default)]
2563    pub property_plots: Vec<PropertyPlotView>,
2564    /// Runtime terrain cell overlays (cultivate dirt → tilled). Replaces prior overlays.
2565    #[serde(default)]
2566    pub terrain_overlays: Vec<TerrainZoneView>,
2567}
2568#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2569pub struct GroundDropView {
2570    pub id: String,
2571    pub template_id: String,
2572    pub quantity: u32,
2573    pub x: f32,
2574    pub y: f32,
2575    pub z: f32,
2576    /// Optional gfx tile id from the item template.
2577    #[serde(default)]
2578    pub tile_id: Option<String>,
2579    /// Item display name (e.g. "Cloth Pants") for hover/chat labels.
2580    #[serde(default)]
2581    pub display_name: Option<String>,
2582    /// Facing yaw in radians (0 = north) for sprite draw on the ground.
2583    #[serde(default)]
2584    pub yaw: f32,
2585    /// Pitch in radians (0 = upright).
2586    #[serde(default)]
2587    pub pitch: f32,
2588    /// Roll in radians (0 = upright; tilts in the view plane).
2589    #[serde(default)]
2590    pub roll: f32,
2591    /// Draw scale relative to one map cell (1.0 = cell size).
2592    #[serde(default = "default_draw_scale")]
2593    pub draw_scale: f32,
2594}
2595
2596fn default_draw_scale() -> f32 {
2597    1.0
2598}
2599
2600/// Full state on region enter or reconnect.
2601#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2602pub struct Snapshot {
2603    pub tick: Tick,
2604    pub chunk_rev: u64,
2605    /// Bumped when blueprints, catalog, segment, or settings reload.
2606    #[serde(default)]
2607    pub content_rev: u64,
2608    /// Stable publish revision from `assets/.content-publish.json` (for client asset sync).
2609    #[serde(default)]
2610    pub publish_rev: u64,
2611    pub entities: Vec<EntityState>,
2612    #[serde(default)]
2613    pub resource_nodes: Vec<ResourceNodeView>,
2614    /// Outdoor play AABB origin (meters). With width/height, defines the composed world.
2615    #[serde(default)]
2616    pub world_x0: f32,
2617    #[serde(default)]
2618    pub world_y0: f32,
2619    /// Segment play area width in meters (for HUD / beyond-zone).
2620    #[serde(default)]
2621    pub world_width_m: f32,
2622    #[serde(default)]
2623    pub world_height_m: f32,
2624    #[serde(default)]
2625    pub buildings: Vec<BuildingView>,
2626    #[serde(default)]
2627    pub doors: Vec<DoorView>,
2628    #[serde(default)]
2629    pub npcs: Vec<NpcView>,
2630    #[serde(default)]
2631    pub inventory: Vec<ItemStack>,
2632    #[serde(default)]
2633    pub blueprints: Vec<BlueprintView>,
2634    /// Wall/roof material packs for the B plot-build menu.
2635    #[serde(default)]
2636    pub building_materials: Vec<BuildingMaterialView>,
2637    #[serde(default)]
2638    pub world_clock: WorldClock,
2639    #[serde(default)]
2640    pub terrain_zones: Vec<TerrainZoneView>,
2641    #[serde(default)]
2642    pub z_platforms: Vec<ZPlatformView>,
2643    #[serde(default)]
2644    pub z_transitions: Vec<ZTransitionView>,
2645    #[serde(default)]
2646    pub ground_drops: Vec<GroundDropView>,
2647    #[serde(default)]
2648    pub placed_containers: Vec<PlacedContainerView>,
2649    #[serde(default)]
2650    pub combat: Option<CombatHud>,
2651    #[serde(default)]
2652    pub interior_map: Option<InteriorMapView>,
2653    #[serde(default)]
2654    pub quest_log: Vec<QuestLogEntry>,
2655    #[serde(default)]
2656    pub hired_workers: Vec<HiredWorkerView>,
2657    #[serde(default)]
2658    pub interactables: Vec<InteractableView>,
2659    #[serde(default)]
2660    pub ledger: Option<PlayerLedgerView>,
2661    #[serde(default)]
2662    pub career: Option<PlayerCareerView>,
2663    /// Active combat footprints / hit markers (`plans/39`).
2664    #[serde(default)]
2665    pub combat_fx: Vec<CombatFx>,
2666    /// Authored crown property zones (claimable land) — plan 40.
2667    #[serde(default)]
2668    pub property_zones: Vec<PropertyZoneView>,
2669    /// Tax overlays (for claim cost premium preview).
2670    #[serde(default)]
2671    pub tax_zones: Vec<TaxZoneView>,
2672    /// Town / security / PvP boundaries (plan 43).
2673    #[serde(default)]
2674    pub boundary_zones: Vec<BoundaryZoneView>,
2675    /// Random-encounter rectangles (plan 54).
2676    #[serde(default)]
2677    pub encounter_zones: Vec<EncounterZoneView>,
2678    /// Growth / fertility overlays (plan 37 / farming).
2679    #[serde(default)]
2680    pub growth_zones: Vec<GrowthZoneView>,
2681    /// Climate / biome overlays (plan 38).
2682    #[serde(default)]
2683    pub biome_zones: Vec<BiomeZoneView>,
2684    /// Terrain kind move speed / impassable table for client pathfinding (from terrain-kinds.yaml).
2685    #[serde(default)]
2686    pub terrain_kind_nav: Vec<TerrainKindNavView>,
2687    /// Claimed property plots in this segment.
2688    #[serde(default)]
2689    pub property_plots: Vec<PropertyPlotView>,
2690    /// Server knobs needed for client claim quote preview.
2691    #[serde(default)]
2692    pub property_plot_settings: Option<PropertyPlotSettingsView>,
2693}
2694
2695/// Harvestable node visible to clients.
2696#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2697pub struct ResourceNodeView {
2698    pub id: String,
2699    pub label: String,
2700    pub x: f32,
2701    pub y: f32,
2702    pub z: f32,
2703    pub item_template: String,
2704    #[serde(default = "default_node_state")]
2705    pub state: ResourceNodeState,
2706    /// When true, players cannot walk through this node while available/harvesting.
2707    #[serde(default = "default_blocking_view")]
2708    pub blocking: bool,
2709    /// Collision radius for pathfinding (meters).
2710    #[serde(default = "default_blocking_radius_view")]
2711    pub blocking_radius_m: f32,
2712    /// Decorative map placement — not harvestable; `blocking` is forced false on the wire.
2713    #[serde(default)]
2714    pub harvest_off: bool,
2715    /// Optional gfx tile id (`resource.oak_log`, …).
2716    #[serde(default)]
2717    pub tile_id: Option<String>,
2718    /// Facing yaw in radians (0 = north). From map placement.
2719    #[serde(default)]
2720    pub yaw: f32,
2721    /// Pitch in radians (0 = upright). From map placement.
2722    #[serde(default)]
2723    pub pitch: f32,
2724    /// Roll in radians (0 = upright). From map placement.
2725    #[serde(default)]
2726    pub roll: f32,
2727    /// Draw scale relative to one map cell (1.0 = cell size).
2728    #[serde(default = "default_draw_scale")]
2729    pub draw_scale: f32,
2730    /// Resolved gfx sprite mode for `tile_id` (server-computed).
2731    #[serde(default)]
2732    pub sprite_mode: Option<String>,
2733    /// Canonical presentation key (`available`, `harvesting`, `depleted`).
2734    #[serde(default)]
2735    pub presentation_state: Option<String>,
2736    /// Farm crop growth 0.0–1.0 while immature; `None` for other nodes and mature crops.
2737    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
2738    #[serde(default)]
2739    pub growth_progress: Option<f32>,
2740    /// Active harvest channel (sim ticks), for progress rings on the node.
2741    #[serde(default)]
2742    pub channel_start_tick: Option<Tick>,
2743    #[serde(default)]
2744    pub channel_end_tick: Option<Tick>,
2745    /// Possible loot templates from this node's harvest loot table (route deposit filters).
2746    #[serde(default)]
2747    pub harvest_drop_templates: Vec<String>,
2748}
2749
2750fn default_blocking_radius_view() -> f32 {
2751    0.8
2752}
2753
2754fn default_blocking_view() -> bool {
2755    true
2756}
2757
2758#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2759#[serde(rename_all = "snake_case")]
2760pub enum ResourceNodeState {
2761    Available,
2762    Harvesting,
2763    Cooldown,
2764}
2765fn default_node_state() -> ResourceNodeState {
2766    ResourceNodeState::Available
2767}
2768
2769/// Live state of a placed world item spawn (plan 45).
2770#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2771#[serde(rename_all = "snake_case")]
2772pub enum ItemSpawnStateView {
2773    Spawned,
2774    PickedUp {
2775        respawn_at_tick: u64,
2776    },
2777    Consumed,
2778}
2779
2780/// Authoritative view of a placed findable item (plan 45) for admin/overseer.
2781#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2782pub struct ItemSpawnView {
2783    pub id: String,
2784    pub label: String,
2785    pub item_template: String,
2786    pub quantity: u32,
2787    pub x: f32,
2788    pub y: f32,
2789    pub z: f32,
2790    pub respawn_ticks: u32,
2791    #[serde(default)]
2792    pub building_id: Option<String>,
2793    pub state: ItemSpawnStateView,
2794}
2795
2796#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2797#[serde(rename_all = "snake_case")]
2798pub enum ItemStatusBindingMode {
2799    OnHit,
2800    WhileEquipped,
2801}
2802
2803impl Default for ItemStatusBindingMode {
2804    fn default() -> Self {
2805        Self::OnHit
2806    }
2807}
2808
2809/// Per-instance status effect binding (unique gear grants / enchantments).
2810#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2811pub struct ItemStatusBinding {
2812    pub effect_id: String,
2813    #[serde(default)]
2814    pub mode: ItemStatusBindingMode,
2815    /// Grant template id, `"loot"`, later `"altar"`, etc.
2816    #[serde(default)]
2817    pub source: String,
2818    #[serde(default)]
2819    pub applied_at_tick: u64,
2820    /// `None` = permanent until overwritten / dispelled.
2821    #[serde(default, skip_serializing_if = "Option::is_none")]
2822    pub expires_at_tick: Option<u64>,
2823}
2824
2825#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2826pub struct ItemStack {
2827    pub template_id: String,
2828    pub quantity: u32,
2829    /// Stable instance id — preserved across checkpoint/sync when set.
2830    #[serde(default)]
2831    pub item_instance_id: Option<Uuid>,
2832    /// Per-instance metadata (stat rolls, soul-bind, lock ids, etc.).
2833    #[serde(default)]
2834    pub props: BTreeMap<String, String>,
2835    /// Instance-only status effects (template effects live on the item def).
2836    #[serde(default)]
2837    pub status_bindings: Vec<ItemStatusBinding>,
2838    /// Nested contents when this stack is a container instance.
2839    #[serde(default)]
2840    pub contents: Vec<ItemStack>,
2841    /// From item catalog when sent on wire (display only).
2842    #[serde(default)]
2843    pub display_name: Option<String>,
2844    /// From item catalog when sent on wire (`weapon`, `consumable`, …).
2845    #[serde(default)]
2846    pub category: Option<String>,
2847    /// Per-unit mass in kg from catalog (display / move UX).
2848    #[serde(default)]
2849    pub base_mass: Option<f32>,
2850    /// Per-unit volume from catalog (display / move UX).
2851    #[serde(default)]
2852    pub base_volume: Option<f32>,
2853    /// Container capacity when this stack is a container template.
2854    #[serde(default)]
2855    pub capacity_volume: Option<f32>,
2856    /// Whether the template stacks in inventory (from catalog).
2857    #[serde(default)]
2858    pub stackable: Option<bool>,
2859    /// Can be placed on the ground from inventory (from catalog).
2860    #[serde(default)]
2861    pub world_placeable: Option<bool>,
2862    /// Hired-worker lodging capacity when this template is placed (from catalog).
2863    #[serde(default)]
2864    pub worker_lodging_capacity: Option<u32>,
2865    /// Body slot this template equips into (from catalog; display / Equip UI).
2866    #[serde(default)]
2867    pub equip_slot: Option<BodySlot>,
2868    /// Template baseline armor rating (from catalog).
2869    #[serde(default)]
2870    pub armor_physical: Option<f32>,
2871    /// Template resists damage_type → value (from catalog).
2872    #[serde(default)]
2873    pub resists: Vec<(String, f32)>,
2874    /// Weapon hand occupancy when category is weapon (`1` or `2`).
2875    #[serde(default)]
2876    pub hand_slots: Option<u8>,
2877    /// Market-hall list eligibility from catalog (display / client pickers).
2878    #[serde(default)]
2879    pub listable: Option<bool>,
2880}
2881
2882impl ItemStack {
2883    pub fn simple(template_id: impl Into<String>, quantity: u32) -> Self {
2884        Self {
2885            template_id: template_id.into(),
2886            quantity,
2887            ..Default::default()
2888        }
2889    }
2890}
2891
2892impl Default for ItemStack {
2893    fn default() -> Self {
2894        Self {
2895            template_id: String::new(),
2896            quantity: 0,
2897            item_instance_id: None,
2898            props: BTreeMap::new(),
2899            status_bindings: Vec::new(),
2900            contents: Vec::new(),
2901            display_name: None,
2902            category: None,
2903            base_mass: None,
2904            base_volume: None,
2905            capacity_volume: None,
2906            stackable: None,
2907            world_placeable: None,
2908            worker_lodging_capacity: None,
2909            equip_slot: None,
2910            armor_physical: None,
2911            resists: Vec::new(),
2912            hand_slots: None,
2913            listable: None,
2914        }
2915    }
2916}
2917
2918/// Carry encumbrance band (`plans/08` §3.2).
2919#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2920#[serde(rename_all = "snake_case")]
2921pub enum EncumbranceState {
2922    #[default]
2923    Light,
2924    Heavy,
2925    Over,
2926}
2927
2928/// Body region a wearable item occupies — at most one item equipped per slot.
2929/// Armor, cloak, jewelry, and carriers (`Back` backpack, `Waist` belt). Hands
2930/// (mainhand / offhand) are separate combat sockets — not `BodySlot`.
2931#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)]
2932#[serde(rename_all = "snake_case")]
2933pub enum BodySlot {
2934    Head,
2935    /// Chest / torso armor. Serde alias `body` for older YAML / saves.
2936    #[serde(alias = "body")]
2937    Chest,
2938    /// Gauntlets / bracers / sleeves. Serde alias `arms` for older YAML / saves.
2939    #[serde(alias = "arms")]
2940    Forearms,
2941    Legs,
2942    Feet,
2943    Cloak,
2944    Back,
2945    Waist,
2946    Earrings,
2947    Necklace,
2948    Eyeglasses,
2949    /// Explicit rename: plain `snake_case` would be `ring_left1`.
2950    #[serde(rename = "ring_left_1", alias = "ring_left1")]
2951    RingLeft1,
2952    #[serde(rename = "ring_left_2", alias = "ring_left2")]
2953    RingLeft2,
2954    #[serde(rename = "ring_right_1", alias = "ring_right1")]
2955    RingRight1,
2956    #[serde(rename = "ring_right_2", alias = "ring_right2")]
2957    RingRight2,
2958}
2959
2960impl BodySlot {
2961    /// All worn slots in paperdoll / catalog order.
2962    pub const ALL: [BodySlot; 15] = [
2963        BodySlot::Head,
2964        BodySlot::Chest,
2965        BodySlot::Forearms,
2966        BodySlot::Legs,
2967        BodySlot::Feet,
2968        BodySlot::Cloak,
2969        BodySlot::Back,
2970        BodySlot::Waist,
2971        BodySlot::Earrings,
2972        BodySlot::Necklace,
2973        BodySlot::Eyeglasses,
2974        BodySlot::RingLeft1,
2975        BodySlot::RingLeft2,
2976        BodySlot::RingRight1,
2977        BodySlot::RingRight2,
2978    ];
2979
2980    pub fn as_str(self) -> &'static str {
2981        match self {
2982            BodySlot::Head => "head",
2983            BodySlot::Chest => "chest",
2984            BodySlot::Forearms => "forearms",
2985            BodySlot::Legs => "legs",
2986            BodySlot::Feet => "feet",
2987            BodySlot::Cloak => "cloak",
2988            BodySlot::Back => "back",
2989            BodySlot::Waist => "waist",
2990            BodySlot::Earrings => "earrings",
2991            BodySlot::Necklace => "necklace",
2992            BodySlot::Eyeglasses => "eyeglasses",
2993            BodySlot::RingLeft1 => "ring_left_1",
2994            BodySlot::RingLeft2 => "ring_left_2",
2995            BodySlot::RingRight1 => "ring_right_1",
2996            BodySlot::RingRight2 => "ring_right_2",
2997        }
2998    }
2999}
3000
3001/// Where an item lives for `MoveItem` / open-container UX.
3002#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3003#[serde(rename_all = "snake_case")]
3004pub enum InventoryLocation {
3005    /// Loose on-person inventory (not inside a worn/placed container).
3006    Root,
3007    /// Inside a worn item (backpack contents, or a pouch clipped onto a worn belt).
3008    Worn { slot: BodySlot },
3009    /// Inside a world-placed container.
3010    Placed { container_id: String },
3011    /// Virtual key ring — only `container_key` items; zero carry mass; persists with combat profile.
3012    Keychain,
3013    /// Virtual whisper pouch — only `whisper_stone` items; zero carry mass.
3014    WhisperPouch,
3015}
3016
3017/// AOI view of a placeable chest on the map.
3018#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3019pub struct PlacedContainerView {
3020    pub id: String,
3021    pub template_id: String,
3022    pub display_name: String,
3023    pub x: f32,
3024    pub y: f32,
3025    pub z: f32,
3026    pub locked: bool,
3027    /// Observer can open (unlocked, or holds matching key).
3028    #[serde(default)]
3029    pub accessible: bool,
3030    #[serde(default)]
3031    pub owner_character_id: Option<Uuid>,
3032    /// Nested contents when `accessible` (empty when locked without key).
3033    #[serde(default)]
3034    pub contents: Vec<ItemStack>,
3035    /// Lock id for matching `container_key` props (`opens_lock_id`).
3036    #[serde(default)]
3037    pub lock_id: Option<String>,
3038    /// Internal storage capacity (liters) from item catalog.
3039    #[serde(default)]
3040    pub capacity_volume: Option<f32>,
3041    /// Container instance id (for MoveItem parent targeting).
3042    #[serde(default)]
3043    pub item_instance_id: Option<Uuid>,
3044    /// Optional gfx tile id from the item template.
3045    #[serde(default)]
3046    pub tile_id: Option<String>,
3047    /// Hired-worker slots when this is placed lodging (`category: lodging`).
3048    #[serde(default)]
3049    pub worker_lodging_capacity: Option<u32>,
3050    /// From item template — blocks movement and autopath when placed.
3051    #[serde(default)]
3052    pub blocking: bool,
3053    /// Collision radius (m) for pathfinding when `blocking`.
3054    #[serde(default)]
3055    pub blocking_radius_m: f32,
3056    /// Interior space when placed indoors (`None` = outdoors). Postcard always
3057    /// serializes optionals so decode stays aligned — bump `PROTOCOL_VERSION`.
3058    #[serde(default)]
3059    pub building_id: Option<String>,
3060}
3061
3062#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3063pub struct BlueprintIngredientView {
3064    pub template_id: String,
3065    pub quantity: u32,
3066    /// Always serialize — `true` is not bool::default() so postcard keeps it; explicit for clarity.
3067    pub consumed: bool,
3068    /// Catalog display name for UI (never show bare template_id when this is set).
3069    #[serde(default)]
3070    pub display_name: String,
3071}
3072
3073#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3074pub struct ToolRequirementView {
3075    pub item: String,
3076    /// Always serialize — postcard omits `false` by default, which breaks roundtrip without explicit value.
3077    pub consumed: bool,
3078    /// Catalog display name for UI.
3079    #[serde(default)]
3080    pub display_name: String,
3081}
3082
3083#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3084pub struct SkillRequirementView {
3085    pub skill: String,
3086    pub level: u32,
3087}
3088
3089#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3090pub struct BlueprintView {
3091    pub id: String,
3092    pub label: String,
3093    pub output: String,
3094    pub output_qty: u32,
3095    pub craft_ticks: u32,
3096    pub inputs: Vec<BlueprintIngredientView>,
3097    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
3098    #[serde(default)]
3099    pub station: Option<String>,
3100    #[serde(default)]
3101    pub category: Option<String>,
3102    #[serde(default)]
3103    pub required_tools: Vec<ToolRequirementView>,
3104    #[serde(default)]
3105    pub skill: Option<SkillRequirementView>,
3106    #[serde(default)]
3107    pub failure_chance: f32,
3108    /// Copper cost for the employer to teach this recipe to a hired worker.
3109    #[serde(default)]
3110    pub worker_train_copper: u64,
3111    /// Catalog display name for `output` (UI must prefer this over the raw template id).
3112    #[serde(default)]
3113    pub output_display_name: String,
3114}
3115
3116/// Per-kind pathfinding params replicated so clients share server content speeds.
3117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3118pub struct TerrainKindNavView {
3119    pub kind: TerrainKindView,
3120    #[serde(default = "default_move_speed_mult_one")]
3121    pub move_speed_mult: f32,
3122    #[serde(default)]
3123    pub impassable: bool,
3124}
3125
3126fn default_move_speed_mult_one() -> f32 {
3127    1.0
3128}
3129
3130/// Terrain overlay from segment YAML (`terrain_zones`).
3131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3132#[serde(rename_all = "snake_case")]
3133pub enum TerrainKindView {
3134    #[default]
3135    Grass,
3136    Dirt,
3137    Tilled,
3138    Desert,
3139    Hill,
3140    Bog,
3141    Beach,
3142    ShallowWater,
3143    DeepWater,
3144    Trail,
3145    Road,
3146    Rock,
3147}
3148
3149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3150pub struct TerrainZoneView {
3151    pub id: String,
3152    pub x0: f32,
3153    pub y0: f32,
3154    pub x1: f32,
3155    pub y1: f32,
3156    #[serde(default)]
3157    pub kind: TerrainKindView,
3158    /// Ground elevation at this zone (m).
3159    #[serde(default)]
3160    pub elevation: f32,
3161    /// Optional map glyph override (single character); falls back to terrain kind catalog.
3162    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
3163    #[serde(default)]
3164    pub glyph: Option<String>,
3165    /// Optional color (`#RRGGBB` or ratatui name); falls back to kind / elevation tint.
3166    #[serde(default)]
3167    pub color: Option<String>,
3168    /// Optional gfx tile id (`terrain.grass`, …) — presentation only.
3169    #[serde(default)]
3170    pub tile_id: Option<String>,
3171    /// Overlap priority — higher wins (`segment.terrain_zones`).
3172    #[serde(default)]
3173    pub z_order: i32,
3174    /// In-progress till/plant channel on this cell (sim ticks).
3175    #[serde(default)]
3176    pub channel_start_tick: Option<Tick>,
3177    #[serde(default)]
3178    pub channel_end_tick: Option<Tick>,
3179}
3180
3181/// Axis-aligned rect used by property / tax zone views.
3182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3183pub struct ZoneRectView {
3184    pub x0: f32,
3185    pub y0: f32,
3186    pub x1: f32,
3187    pub y1: f32,
3188}
3189
3190/// Crown property zone (claimable land) — plan 40.
3191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3192pub struct PropertyZoneView {
3193    pub id: String,
3194    /// Designer label when present; clients prefer this over id.
3195    #[serde(default)]
3196    pub label: Option<String>,
3197    pub rects: Vec<ZoneRectView>,
3198    #[serde(default)]
3199    pub z_order: i32,
3200    pub crown_price_copper: u64,
3201    pub upkeep_copper_per_day: u64,
3202    #[serde(default)]
3203    pub max_area_m2: Option<f32>,
3204    #[serde(default)]
3205    pub owner_tax_discount_bps: u32,
3206}
3207
3208/// Tax overlay for claim premium preview.
3209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3210pub struct TaxZoneView {
3211    pub id: String,
3212    #[serde(default)]
3213    pub label: Option<String>,
3214    pub rects: Vec<ZoneRectView>,
3215    #[serde(default)]
3216    pub z_order: i32,
3217    pub rate_bps: u32,
3218    #[serde(default)]
3219    pub flat_copper: u64,
3220    /// Market-hall sales tax in basis points of sale total.
3221    #[serde(default)]
3222    pub market_sales_tax_bps: u32,
3223    /// Optional flat copper per market-hall purchase.
3224    #[serde(default)]
3225    pub market_sales_flat_copper: u32,
3226}
3227
3228/// Town / security / PvP boundary overlay (plan 43).
3229#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3230pub struct BoundaryZoneView {
3231    pub id: String,
3232    #[serde(default)]
3233    pub label: Option<String>,
3234    pub rects: Vec<ZoneRectView>,
3235    #[serde(default)]
3236    pub z_order: i32,
3237    #[serde(default, skip_serializing_if = "Option::is_none")]
3238    pub jurisdiction_id: Option<String>,
3239    #[serde(default = "default_true")]
3240    pub worker_logistics: bool,
3241    #[serde(default)]
3242    pub security_tier: String,
3243    #[serde(default)]
3244    pub pvp_mode: String,
3245    #[serde(default = "default_true")]
3246    pub crime_enabled: bool,
3247    #[serde(default)]
3248    pub guard_response: bool,
3249}
3250
3251/// Random encounter overlay (plan 54).
3252#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3253pub struct EncounterZoneView {
3254    pub id: String,
3255    #[serde(default)]
3256    pub label: Option<String>,
3257    pub rects: Vec<ZoneRectView>,
3258    #[serde(default)]
3259    pub z_order: i32,
3260}
3261
3262fn default_true() -> bool {
3263    true
3264}
3265
3266/// Growth / fertility overlay — faster respawn & farm tuning (plan 37 / 40).
3267#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3268pub struct GrowthZoneView {
3269    pub id: String,
3270    #[serde(default)]
3271    pub label: Option<String>,
3272    pub rects: Vec<ZoneRectView>,
3273    #[serde(default)]
3274    pub z_order: i32,
3275    #[serde(default = "default_one_f32")]
3276    pub fertility: f32,
3277}
3278
3279fn default_one_f32() -> f32 {
3280    1.0
3281}
3282
3283/// Climate / biome overlay (plan 38).
3284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3285pub struct BiomeZoneView {
3286    pub id: String,
3287    #[serde(default)]
3288    pub label: Option<String>,
3289    pub rects: Vec<ZoneRectView>,
3290    #[serde(default)]
3291    pub z_order: i32,
3292    pub biome_id: String,
3293}
3294
3295/// Named tenant on a property plot (farm access + tax discount).
3296#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3297pub struct FarmGrantView {
3298    pub character_id: Uuid,
3299    /// Display name when known (AOI / online); empty if offline-only id.
3300    #[serde(default)]
3301    pub character_label: String,
3302    pub tax_discount_bps: u32,
3303}
3304
3305/// Claimed plot visible to clients (plan 40).
3306#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3307pub struct PropertyPlotView {
3308    pub plot_id: Uuid,
3309    pub property_zone_id: String,
3310    #[serde(default)]
3311    pub zone_label: Option<String>,
3312    pub deed_instance_id: Uuid,
3313    pub x0: f32,
3314    pub y0: f32,
3315    pub x1: f32,
3316    pub y1: f32,
3317    pub upkeep_copper_per_day: u64,
3318    pub arrears_days: u32,
3319    /// Observer currently holds this plot's deed.
3320    #[serde(default)]
3321    pub is_mine: bool,
3322    /// Observer may cultivate/plant/harvest on this plot (deed, owner, public, or grant).
3323    #[serde(default)]
3324    pub may_farm: bool,
3325    /// Book value when known (purchase / last private sale).
3326    #[serde(default)]
3327    pub purchase_basis_copper: u64,
3328    #[serde(default)]
3329    pub farm_public: bool,
3330    #[serde(default)]
3331    pub public_tax_discount_bps: u32,
3332    #[serde(default)]
3333    pub farm_allow: Vec<FarmGrantView>,
3334    /// Owner character when known.
3335    #[serde(default)]
3336    pub owner_character_id: Option<Uuid>,
3337    #[serde(default)]
3338    pub owner_label: Option<String>,
3339    /// Player building on this plot, if any.
3340    #[serde(default)]
3341    pub building_id: Option<String>,
3342    /// Immutable short code derived from `plot_id` (e.g. `xyz1234a`).
3343    #[serde(default)]
3344    pub plot_code: String,
3345    /// Owner-chosen label (defaults to `plot_code`).
3346    #[serde(default)]
3347    pub label: String,
3348}
3349
3350/// Subset of server settings for client-side claim quotes.
3351#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3352pub struct PropertyPlotSettingsView {
3353    pub min_plot_area_m2: f32,
3354    pub tax_premium_weight: f32,
3355    pub sellback_bps: u32,
3356}
3357
3358/// Walkable platform at a fixed z (`segment.z_platforms`).
3359#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3360pub struct ZPlatformView {
3361    pub id: String,
3362    pub z: f32,
3363    pub x0: f32,
3364    pub y0: f32,
3365    pub x1: f32,
3366    pub y1: f32,
3367}
3368
3369/// Stairs / ramp linking two z bands (`segment.z_transitions`).
3370#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3371pub struct ZTransitionView {
3372    pub id: String,
3373    pub z_from: f32,
3374    pub z_to: f32,
3375    pub x0: f32,
3376    pub y0: f32,
3377    pub x1: f32,
3378    pub y1: f32,
3379}
3380
3381#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3382pub struct BuildingView {
3383    pub id: String,
3384    pub label: String,
3385    pub x: f32,
3386    pub y: f32,
3387    pub width_m: f32,
3388    pub depth_m: f32,
3389    #[serde(default)]
3390    pub interior_blueprint: Option<String>,
3391    #[serde(default)]
3392    pub tags: Vec<String>,
3393    /// Boundary zones this market hall participates in (`plans/10`).
3394    #[serde(default)]
3395    pub market_boundary_zone_ids: Vec<String>,
3396    /// Escrow capacity when tagged `market`. None → server default.
3397    #[serde(default)]
3398    pub market_max_volume: Option<f32>,
3399    /// Wall art set id (`schemas/gfx-sprites.schema.json` `wall_set`). `None` → `classic_stone`
3400    /// (legacy `building.wall_h`/`wall_v`/`wall_corner`/`door_open`/`door_closed` sprites).
3401    #[serde(default)]
3402    pub wall_set: Option<String>,
3403    /// Roof art set id (forge `roof_set` output_stem). `None` → `classic_stone`.
3404    #[serde(default)]
3405    pub roof_set: Option<String>,
3406}
3407
3408/// Default wall/roof set id when a building doesn't specify one — keeps existing
3409/// content rendering pixel-identical (`plans/47` Workstream C).
3410pub const DEFAULT_BUILDING_ART_SET: &str = "classic_stone";
3411
3412impl BuildingView {
3413    pub fn effective_wall_set(&self) -> &str {
3414        self.wall_set
3415            .as_deref()
3416            .filter(|s| !s.is_empty())
3417            .unwrap_or(DEFAULT_BUILDING_ART_SET)
3418    }
3419
3420    pub fn effective_roof_set(&self) -> &str {
3421        self.roof_set
3422            .as_deref()
3423            .filter(|s| !s.is_empty())
3424            .unwrap_or(DEFAULT_BUILDING_ART_SET)
3425    }
3426}
3427
3428#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3429pub struct DoorView {
3430    pub id: String,
3431    pub building_id: String,
3432    pub x: f32,
3433    pub y: f32,
3434    #[serde(default)]
3435    pub open: bool,
3436    #[serde(default)]
3437    pub portal: Option<String>,
3438    /// Exterior door locked (player buildings). Locked doors cannot be opened until unlocked.
3439    #[serde(default)]
3440    pub locked: bool,
3441    /// Observer can open/close/enter (`!locked`). Key is only for lock/unlock.
3442    #[serde(default = "default_door_accessible")]
3443    pub accessible: bool,
3444    #[serde(default)]
3445    pub lock_id: Option<Uuid>,
3446}
3447
3448fn default_door_accessible() -> bool {
3449    true
3450}
3451
3452/// Client → server interior room layout (confirm edit).
3453#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3454pub struct InteriorRoomEdit {
3455    pub id: String,
3456    pub label: String,
3457    pub x0: f32,
3458    pub y0: f32,
3459    pub x1: f32,
3460    pub y1: f32,
3461}
3462
3463#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3464pub struct InteriorRoomDoorEdit {
3465    pub id: String,
3466    pub room_a: String,
3467    pub room_b: String,
3468    pub x: f32,
3469    pub y: f32,
3470}
3471
3472#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3473pub struct InteriorRoomView {
3474    pub id: String,
3475    pub label: String,
3476    pub floor: i32,
3477    pub x0: f32,
3478    pub y0: f32,
3479    pub x1: f32,
3480    pub y1: f32,
3481    #[serde(default)]
3482    pub floor_color: Option<String>,
3483    #[serde(default)]
3484    pub floor_glyph: Option<String>,
3485}
3486
3487#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3488pub struct InteriorDoorView {
3489    pub id: String,
3490    pub room_a: String,
3491    pub room_b: String,
3492    pub x: f32,
3493    pub y: f32,
3494    pub kind: String,
3495    #[serde(default)]
3496    pub x_a: Option<f32>,
3497    #[serde(default)]
3498    pub y_a: Option<f32>,
3499    #[serde(default)]
3500    pub x_b: Option<f32>,
3501    #[serde(default)]
3502    pub y_b: Option<f32>,
3503}
3504
3505#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3506pub struct InteriorMapView {
3507    pub building_id: String,
3508    pub blueprint_id: String,
3509    pub background_color: String,
3510    #[serde(default)]
3511    pub default_floor_color: Option<String>,
3512    #[serde(default = "default_floor_height_view")]
3513    pub floor_height_m: f32,
3514    /// Walkable platforms per floor (interior z-bands).
3515    #[serde(default)]
3516    pub z_platforms: Vec<ZPlatformView>,
3517    #[serde(default)]
3518    pub z_transitions: Vec<ZTransitionView>,
3519    pub rooms: Vec<InteriorRoomView>,
3520    #[serde(default)]
3521    pub room_doors: Vec<InteriorDoorView>,
3522}
3523
3524fn default_floor_height_view() -> f32 {
3525    3.0
3526}
3527
3528#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3529pub struct NpcView {
3530    pub id: String,
3531    pub label: String,
3532    pub role: String,
3533    pub x: f32,
3534    pub y: f32,
3535    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
3536    #[serde(default)]
3537    pub building_id: Option<String>,
3538    /// Authoritative sim entity for wildlife / combat targets.
3539    #[serde(default)]
3540    pub entity_id: Option<EntityId>,
3541    #[serde(default)]
3542    pub life_state: Option<LifeState>,
3543    #[serde(default)]
3544    pub hp_pct: Option<f32>,
3545    /// True when this NPC has buy/sell/teach offers (`npc_has_market`).
3546    #[serde(default)]
3547    pub can_trade: bool,
3548    /// Gfx sprite sheet id (`assets/gfx/sprites/`). Client falls back to `npc.{id}` / `npc.{role}`.
3549    #[serde(default)]
3550    pub tile_id: Option<String>,
3551    /// Wildlife FSM state (`idle`, `chase`, `combat`, …) when behavior-driven (debug).
3552    #[serde(default)]
3553    pub behavior_state: Option<String>,
3554    /// Canonical gfx presentation key (`combat`, `pursue`, `walking`, `talking`, …).
3555    #[serde(default)]
3556    pub presentation_state: Option<String>,
3557    /// Resolved gfx sprite mode for `tile_id` (server-computed).
3558    #[serde(default)]
3559    pub sprite_mode: Option<String>,
3560    /// Paperdoll skin id (`assets/paperdoll/skins/`). Client prefers this over `tile_id` when baked.
3561    #[serde(default)]
3562    pub paperdoll_ref: Option<String>,
3563    /// World draw size in map cells (from paperdoll skin `draw_scale`; 1.0 = one cell).
3564    #[serde(default = "default_draw_scale")]
3565    pub draw_scale: f32,
3566}
3567
3568#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3569pub struct UseResult {
3570    pub template_id: String,
3571    pub hunger_restored: f32,
3572    pub thirst_restored: f32,
3573    #[serde(default)]
3574    pub health_restored: f32,
3575    #[serde(default)]
3576    pub mana_restored: f32,
3577    #[serde(default)]
3578    pub cleared_dot_ids: Vec<String>,
3579}
3580
3581#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3582pub struct CraftResult {
3583    pub blueprint_id: String,
3584    pub outputs: Vec<ItemStack>,
3585    pub consumed: Vec<ItemStack>,
3586    /// 1-based index within the submitted batch (1 when not batching).
3587    #[serde(default = "default_one")]
3588    pub batch_index: u32,
3589    /// Total crafts requested in this batch (1 when not batching).
3590    #[serde(default = "default_one")]
3591    pub batch_total: u32,
3592}
3593
3594fn default_one() -> u32 {
3595    1
3596}
3597
3598#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3599pub struct DeathNotice {
3600    pub entity_id: EntityId,
3601    pub respawn_x: f32,
3602    pub respawn_y: f32,
3603    pub message: String,
3604}
3605
3606#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3607pub struct InteractionNotice {
3608    pub target_id: String,
3609    pub message: String,
3610    #[serde(default)]
3611    pub coins_delta: i32,
3612    #[serde(default)]
3613    pub inventory_delta: Vec<ItemStack>,
3614}
3615
3616#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3617#[serde(rename_all = "snake_case")]
3618pub enum NpcTalkTrustFlag {
3619    Stranger,
3620    Acquainted,
3621    Trusted,
3622}
3623
3624#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
3625#[serde(rename_all = "snake_case")]
3626pub enum NpcTalkDepth {
3627    #[default]
3628    Full,
3629    Brief,
3630    Unavailable,
3631}
3632
3633#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3634pub struct NpcTalkOpened {
3635    pub npc_id: String,
3636    pub npc_label: String,
3637    pub greeting: String,
3638    pub trust_flag: NpcTalkTrustFlag,
3639    #[serde(default)]
3640    pub talk_depth: NpcTalkDepth,
3641    #[serde(default = "default_true")]
3642    pub trade_allowed: bool,
3643}
3644
3645#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3646pub struct NpcTalkPending {
3647    pub npc_id: String,
3648}
3649
3650#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3651pub struct NpcTalkReply {
3652    pub npc_id: String,
3653    pub line: String,
3654    pub trust_flag: NpcTalkTrustFlag,
3655    #[serde(default)]
3656    pub wind_down: bool,
3657    #[serde(default)]
3658    pub trade_disabled: bool,
3659}
3660
3661#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3662pub struct NpcTalkClosed {
3663    pub npc_id: String,
3664}
3665
3666#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3667pub struct NpcTalkError {
3668    pub npc_id: String,
3669    pub reason: String,
3670}
3671
3672#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3673#[serde(rename_all = "snake_case")]
3674pub enum QuestStatusView {
3675    Available,
3676    Active,
3677    Completed,
3678}
3679
3680#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3681pub struct QuestObjectiveProgress {
3682    pub label: String,
3683    pub current: u32,
3684    pub required: u32,
3685    pub done: bool,
3686}
3687
3688#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3689pub struct QuestLogEntry {
3690    pub quest_id: String,
3691    pub title: String,
3692    pub description: String,
3693    pub status: QuestStatusView,
3694    #[serde(default)]
3695    pub current_step_id: Option<String>,
3696    #[serde(default)]
3697    pub current_step_title: String,
3698    #[serde(default)]
3699    pub objectives: Vec<QuestObjectiveProgress>,
3700    #[serde(default)]
3701    pub is_tracked: bool,
3702    #[serde(default)]
3703    pub can_withdraw: bool,
3704}
3705
3706#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3707pub struct InteractableView {
3708    pub id: String,
3709    pub kind: String,
3710    pub label: String,
3711    pub x: f32,
3712    pub y: f32,
3713    pub z: f32,
3714    #[serde(default)]
3715    pub board_id: Option<String>,
3716}
3717
3718#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3719pub struct QuestOffer {
3720    pub quest_id: String,
3721    pub title: String,
3722    pub description: String,
3723    #[serde(default)]
3724    pub step_count: u32,
3725}
3726
3727#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3728pub struct QuestNotice {
3729    pub quest_id: String,
3730    pub title: String,
3731    pub message: String,
3732}
3733
3734#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3735#[serde(rename_all = "snake_case")]
3736pub enum ShopOfferKind {
3737    Item,
3738    Blueprint,
3739}
3740
3741#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3742pub struct ShopOffer {
3743    pub offer_id: String,
3744    pub kind: ShopOfferKind,
3745    pub label: String,
3746    #[serde(default)]
3747    pub template_id: Option<String>,
3748    #[serde(default)]
3749    pub blueprint_id: Option<String>,
3750    pub price_copper: u32,
3751    #[serde(default)]
3752    pub affordable: bool,
3753    #[serde(default)]
3754    pub already_owned: bool,
3755}
3756
3757#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3758pub struct ShopBuyLine {
3759    pub template_id: String,
3760    pub label: String,
3761    pub quantity: u32,
3762    pub price_copper: u32,
3763}
3764
3765/// Bank teller interaction panel (`plans/08` §8).
3766#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3767pub struct BankPanel {
3768    pub npc_id: String,
3769    pub npc_label: String,
3770    pub bank_balance_copper: u64,
3771    pub on_person_copper: u64,
3772    /// Copper still clearing to other accounts (debited, not yet credited).
3773    #[serde(default)]
3774    pub pending_outgoing_copper: u64,
3775    #[serde(default)]
3776    pub transfer_fee_bps: u32,
3777    #[serde(default)]
3778    pub transfer_clear_ticks: u64,
3779}
3780
3781/// Town storage manager panel (`plans/08` §7).
3782#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3783pub struct StoragePanel {
3784    pub npc_id: String,
3785    pub npc_label: String,
3786    pub building_id: String,
3787    pub building_label: String,
3788    pub used_volume: f32,
3789    pub max_volume: f32,
3790    #[serde(default)]
3791    pub contents: Vec<ItemStack>,
3792    /// Other storage buildings that can receive a ship (id, label, distance_m, fee_copper, travel_ticks).
3793    #[serde(default)]
3794    pub ship_destinations: Vec<StorageShipDest>,
3795}
3796
3797#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3798pub struct StorageShipDest {
3799    pub building_id: String,
3800    pub label: String,
3801    pub distance_m: f32,
3802    pub fee_copper: u64,
3803    pub travel_ticks: u64,
3804}
3805
3806/// Source or destination for market-hall goods movement
3807/// (`plans/10-economy-and-markets.md` §4.3/§4.4).
3808#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3809pub enum GoodsLocation {
3810    /// On-person inventory (root, non-nested).
3811    Person,
3812    /// A town storage vault at a building whose jurisdiction intersects the
3813    /// listing hall's `market_boundary_zone_ids`.
3814    TownStorage { building_id: String },
3815}
3816
3817/// One escrowed market-hall listing, as seen from a browsing hall
3818/// (`plans/10-economy-and-markets.md` §4.2).
3819#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3820pub struct MarketListingView {
3821    pub listing_id: Uuid,
3822    pub seller_character_id: Uuid,
3823    /// Seller display name (`prefer-labels-over-ids`).
3824    pub seller_label: String,
3825    pub hall_building_id: String,
3826    pub hall_label: String,
3827    pub template_id: String,
3828    pub display_name: String,
3829    /// Item catalog category (`resource`, `weapon`, …) for client filters.
3830    #[serde(default)]
3831    pub category: String,
3832    pub quantity: u32,
3833    pub unit_price_copper: u64,
3834    /// Total for the full remaining quantity (`quantity * unit_price_copper`).
3835    pub line_total_copper: u64,
3836    /// Dump-queue listing — players cannot buy (`plans/53`).
3837    #[serde(default)]
3838    pub npc_price: bool,
3839    /// True when the viewer is the seller — reprice/delist allowed.
3840    pub mine: bool,
3841}
3842
3843/// Town storage vault eligible as a list/buy/delist goods source for a market hall.
3844#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3845pub struct MarketListVault {
3846    pub building_id: String,
3847    /// Designer label (`prefer-labels-over-ids`).
3848    pub building_label: String,
3849    #[serde(default)]
3850    pub contents: Vec<ItemStack>,
3851}
3852
3853/// Market hall clerk panel — browse (zone-linked halls), list / reprice / delist,
3854/// buy confirm (`plans/10-economy-and-markets.md` §4, §10).
3855#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3856pub struct MarketPanel {
3857    pub npc_id: String,
3858    pub npc_label: String,
3859    pub building_id: String,
3860    pub building_label: String,
3861    /// Escrow volume used at *this* hall only (cap is per-hall, `market_max_volume`).
3862    pub used_volume: f32,
3863    pub max_volume: f32,
3864    /// Listings at this hall plus any zone-linked halls (`market_boundary_zone_ids`
3865    /// intersection) — the shared browse book (§4.2).
3866    #[serde(default)]
3867    pub listings: Vec<MarketListingView>,
3868    /// Crown sales tax at the tax zone covering this hall (§6).
3869    #[serde(default)]
3870    pub tax_bps: u32,
3871    #[serde(default)]
3872    pub tax_flat_copper: u32,
3873    /// Zone-eligible town storage vaults the caller can list from (§4.3).
3874    #[serde(default)]
3875    pub list_vaults: Vec<MarketListVault>,
3876}
3877
3878#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3879pub struct ShopCatalog {
3880    pub npc_id: String,
3881    pub npc_label: String,
3882    #[serde(default)]
3883    pub sells: Vec<ShopOffer>,
3884    #[serde(default)]
3885    pub buys: Vec<ShopBuyLine>,
3886}
3887
3888#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3889pub struct HarvestResult {
3890    pub node_id: String,
3891    /// Stack quantity granted (not one-node-one-instance).
3892    pub quantity: u32,
3893    pub item_template: String,
3894    /// Optional DB row id when persisted to control plane.
3895    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
3896    #[serde(default)]
3897    pub item_instance_id: Option<Uuid>,
3898}
3899
3900/// Every on-wire payload is wrapped for versioning and codec uniformity.
3901#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3902pub struct Envelope<T> {
3903    pub protocol_version: u16,
3904    pub payload: T,
3905}
3906
3907impl<T> Envelope<T> {
3908    pub fn new(payload: T) -> Self {
3909        Self {
3910            protocol_version: crate::PROTOCOL_VERSION,
3911            payload,
3912        }
3913    }
3914}
3915
3916/// Session handshake after transport connect.
3917#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3918pub struct Hello {
3919    pub client_name: String,
3920    pub protocol_version: u16,
3921    #[serde(default)]
3922    pub auth: AuthCredential,
3923    /// Required for session auth; embedded in `ApiToken` variant otherwise.
3924    #[serde(default)]
3925    pub character_id: Option<Uuid>,
3926}
3927
3928/// How the client authenticates to the game gateway.
3929/// Uses default serde enum encoding (postcard-compatible; not internally tagged).
3930#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3931#[serde(rename_all = "snake_case")]
3932pub enum AuthCredential {
3933    DevLocal,
3934    Session { token: String },
3935    ApiToken { token: String, character_id: Uuid },
3936}
3937
3938impl Default for AuthCredential {
3939    fn default() -> Self {
3940        Self::DevLocal
3941    }
3942}
3943
3944#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3945pub struct Welcome {
3946    pub session_id: SessionId,
3947    pub entity_id: EntityId,
3948    pub snapshot: Snapshot,
3949}
3950
3951#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3952pub enum ServerMessage {
3953    Welcome(Welcome),
3954    /// Static world layers refreshed (blueprints, catalog, map, terrain) — no client restart.
3955    ContentUpdated(Snapshot),
3956    Tick(TickDelta),
3957    IntentAck {
3958        entity_id: EntityId,
3959        seq: Seq,
3960        tick: Tick,
3961    },
3962    Chat(ChatMessage),
3963    HarvestResult(HarvestResult),
3964    UseResult(UseResult),
3965    CraftResult(CraftResult),
3966    Death(DeathNotice),
3967    Interaction(InteractionNotice),
3968    ShopOpened(ShopCatalog),
3969    NpcTalkOpened(NpcTalkOpened),
3970    NpcTalkPending(NpcTalkPending),
3971    NpcTalkReply(NpcTalkReply),
3972    NpcTalkClosed(NpcTalkClosed),
3973    NpcTalkError(NpcTalkError),
3974    QuestOffer(QuestOffer),
3975    QuestAccepted(QuestNotice),
3976    QuestWithdrawn(QuestNotice),
3977    QuestStepCompleted(QuestNotice),
3978    QuestCompleted(QuestNotice),
3979    /// Bank teller panel opened (`plans/08` §8).
3980    BankOpened(BankPanel),
3981    /// Town storage manager panel opened (`plans/08` §7).
3982    StorageOpened(StoragePanel),
3983    /// Market hall clerk panel opened or refreshed (`plans/10-economy-and-markets.md`).
3984    MarketOpened(MarketPanel),
3985    /// Player-to-player trade window opened or refreshed.
3986    TradeOpened(TradePanel),
3987    /// Trade ended (cancel, complete, or peer left range).
3988    TradeClosed {
3989        reason: String,
3990    },
3991    /// Hello accepted but play refused (character already online, etc.).
3992    /// Sent instead of Welcome; TCP closes afterward.
3993    ConnectRejected {
3994        reason: String,
3995    },
3996}
3997
3998/// Live player-to-player trade escrow view (both sides see the same presented buckets).
3999#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4000pub struct TradePanel {
4001    pub peer_entity_id: EntityId,
4002    pub peer_name: String,
4003    pub my_presented: Vec<ItemStack>,
4004    pub their_presented: Vec<ItemStack>,
4005    pub i_ready: bool,
4006    pub they_ready: bool,
4007    /// Predicted carry mass after accepting their presented items (and losing mine).
4008    pub my_mass_after: f32,
4009    pub my_mass_max: f32,
4010    pub my_encumbrance_after: EncumbranceState,
4011    /// True when completing would put the local player Over.
4012    pub overburden_warning: bool,
4013}
4014
4015#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4016pub enum ClientMessage {
4017    Hello(Hello),
4018    Intent(Intent),
4019    Disconnect,
4020}
4021
4022#[cfg(test)]
4023mod tests {
4024    use super::*;
4025
4026    #[test]
4027    fn pristine_vitals_state_yields_full_pools() {
4028        let attrs = PrimaryAttributes::default();
4029        let vitals = StoredVitalsState::default().apply_to(attrs);
4030        assert!(vitals.health > 0.0);
4031        assert_eq!(vitals.health, vitals.health_max);
4032        assert!((vitals.mana_max - 61.0).abs() < 0.01);
4033    }
4034
4035    #[test]
4036    fn humanize_snake_id_title_cases_parts() {
4037        assert_eq!(humanize_snake_id("heal_touch"), "Heal Touch");
4038        assert_eq!(humanize_snake_id("fireball"), "Fireball");
4039        assert_eq!(humanize_snake_id("cone_frost"), "Cone Frost");
4040    }
4041
4042    #[test]
4043    fn saved_vitals_scale_when_pool_max_increases() {
4044        let mut attrs = PrimaryAttributes::default();
4045        attrs.intelligence = 140;
4046        attrs.wisdom = 140;
4047        let saved = StoredVitalsState {
4048            health: 100.0,
4049            mana: 14.0,
4050            stamina: 100.0,
4051            ..StoredVitalsState::default()
4052        };
4053        let vitals = saved.apply_to(attrs);
4054        assert!(vitals.mana_max > 55.0);
4055        assert!(
4056            (vitals.mana - vitals.mana_max).abs() < 0.01,
4057            "full legacy mana bar migrates to full new bar"
4058        );
4059    }
4060
4061    #[test]
4062    fn empty_vitals_state_is_pristine() {
4063        let pristine = StoredVitalsState {
4064            health: 0.0,
4065            mana: 0.0,
4066            stamina: 0.0,
4067            hunger: 0.0,
4068            thirst: 0.0,
4069            coins: 0,
4070            deaths: 0,
4071            life_state: LifeState::Alive,
4072        };
4073        assert!(pristine.is_pristine());
4074        let vitals = pristine.apply_to(PrimaryAttributes::default());
4075        assert!(vitals.health > 0.0);
4076    }
4077
4078    #[test]
4079    fn stored_vitals_roundtrip_preserves_partial_pools() {
4080        let attrs = PrimaryAttributes::default();
4081        let mut live = PlayerVitals::from_attributes(attrs);
4082        live.health = 25.0;
4083        live.hunger = 77.0;
4084        live.deaths = 2;
4085        let stored = StoredVitalsState::from_live(&live);
4086        let restored = stored.apply_to(attrs);
4087        assert!(
4088            (restored.health - 25.0).abs() < 0.01,
4089            "partial HP below cap stays absolute"
4090        );
4091        assert_eq!(restored.hunger, 77.0);
4092        assert_eq!(restored.deaths, 2);
4093    }
4094
4095    #[test]
4096    fn skill_tiers_start_at_zero() {
4097        let skill = SkillProgress::default();
4098        assert_eq!(skill.level, 0);
4099        assert_eq!(skill.display_tier(), 0);
4100        let trained = SkillProgress {
4101            level: 250,
4102            last_trained_tick: 1,
4103        };
4104        assert_eq!(trained.display_tier(), 2);
4105    }
4106
4107    #[test]
4108    fn quest_server_messages_roundtrip_json() {
4109        use crate::codec::{Codec, PostcardCodec};
4110
4111        let offer = ServerMessage::QuestOffer(QuestOffer {
4112            quest_id: "ada_goblin_hunt".into(),
4113            title: "Goblin Trouble".into(),
4114            description: "Help Ada".into(),
4115            step_count: 3,
4116        });
4117        let notice = ServerMessage::QuestAccepted(QuestNotice {
4118            quest_id: "ada_goblin_hunt".into(),
4119            title: "Goblin Trouble".into(),
4120            message: "Quest accepted".into(),
4121        });
4122        for msg in [offer, notice] {
4123            let bytes = PostcardCodec.encode(&msg).unwrap();
4124            let decoded: ServerMessage = PostcardCodec.decode(&bytes).unwrap();
4125            assert_eq!(decoded, msg);
4126        }
4127    }
4128
4129    #[test]
4130    fn hotbar_consumable_binding_roundtrips() {
4131        let binding = hotbar_consumable_binding("bottle_of_water");
4132        assert_eq!(binding, "item:bottle_of_water");
4133        assert!(hotbar_binding_is_consumable(&binding));
4134        assert_eq!(
4135            hotbar_consumable_template(&binding),
4136            Some("bottle_of_water")
4137        );
4138        assert!(!hotbar_binding_is_consumable("fireball"));
4139        assert_eq!(hotbar_consumable_template("fireball"), None);
4140    }
4141}