Skip to main content

flatland_protocol/
types.rs

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