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