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