Skip to main content

flatland_protocol/
types.rs

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