Skip to main content

flatland_protocol/
types.rs

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