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
21impl WorldCoord {
22    pub fn surface(x: f32, y: f32) -> Self {
23        Self {
24            x,
25            y,
26            z: 0.0,
27            w: 0,
28            t: 0,
29        }
30    }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
34pub struct Velocity2D {
35    pub vx: f32,
36    pub vy: f32,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
40pub struct Transform {
41    pub position: WorldCoord,
42    pub yaw: f32,
43    pub velocity: Velocity2D,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum LifeState {
49    Alive,
50    Dead,
51}
52
53impl Default for LifeState {
54    fn default() -> Self {
55        Self::Alive
56    }
57}
58
59/// Named slice of the in-game day (drives future weather / spawn tables).
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum TimeOfDayPhase {
63    Night,
64    Dawn,
65    Morning,
66    Midday,
67    Afternoon,
68    Evening,
69}
70
71impl TimeOfDayPhase {
72    pub fn label(self) -> &'static str {
73        match self {
74            Self::Night => "Night",
75            Self::Dawn => "Dawn",
76            Self::Morning => "Morning",
77            Self::Midday => "Midday",
78            Self::Afternoon => "Afternoon",
79            Self::Evening => "Evening",
80        }
81    }
82
83    pub fn from_name(name: &str) -> Self {
84        match name.to_ascii_lowercase().as_str() {
85            "dawn" => Self::Dawn,
86            "morning" => Self::Morning,
87            "midday" | "mid_day" | "noon" => Self::Midday,
88            "afternoon" => Self::Afternoon,
89            "evening" | "dusk" => Self::Evening,
90            _ => Self::Night,
91        }
92    }
93}
94
95/// Authoritative region clock (one in-game day = configurable real duration).
96#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
97pub struct WorldClock {
98    /// Days elapsed since region start (0-based).
99    pub day: u64,
100    pub hour: u8,
101    pub minute: u8,
102    pub phase: TimeOfDayPhase,
103}
104
105impl Default for WorldClock {
106    fn default() -> Self {
107        Self {
108            day: 0,
109            hour: 8,
110            minute: 0,
111            phase: TimeOfDayPhase::Morning,
112        }
113    }
114}
115
116impl WorldClock {
117    pub fn display_time(self) -> String {
118        format!("{:02}:{:02}", self.hour, self.minute)
119    }
120}
121
122/// Core primary stats — internal scale 1–1000; UI displays `value / 10` (1–100).
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124pub struct PrimaryAttributes {
125    pub strength: u16,
126    pub dexterity: u16,
127    pub intelligence: u16,
128    pub stamina: u16,
129    pub vitality: u16,
130    pub wisdom: u16,
131    pub charisma: u16,
132}
133
134impl Default for PrimaryAttributes {
135    fn default() -> Self {
136        Self {
137            strength: 500,
138            dexterity: 500,
139            intelligence: 500,
140            stamina: 500,
141            vitality: 500,
142            wisdom: 500,
143            charisma: 500,
144        }
145    }
146}
147
148impl PrimaryAttributes {
149    /// Map internal 1–1000 to player-facing 1–100.
150    pub fn display(value: u16) -> u16 {
151        (value / 10).clamp(1, 100)
152    }
153
154    /// Client-facing derived stat preview (mirrors sim tuning).
155    pub fn derived_preview(&self) -> DerivedPreview {
156        let str_d = Self::display(self.strength) as f32;
157        let dex_d = Self::display(self.dexterity) as f32;
158        let int_d = Self::display(self.intelligence) as f32;
159        let wis_d = Self::display(self.wisdom) as f32;
160        DerivedPreview {
161            attack_power: str_d * 1.2 + dex_d * 0.3,
162            spell_power: int_d * 1.1 + wis_d * 0.4,
163            evasion: dex_d * 0.8 + wis_d * 0.2,
164            carry_mass_max: str_d * 2.5,
165            sight_range_m: 12.0 + wis_d * 0.15 + dex_d * 0.05,
166            fov_deg: 120.0 + wis_d * 0.2,
167        }
168    }
169}
170
171/// Derived combat/survival preview for character sheet UI.
172#[derive(Debug, Clone, Copy, PartialEq)]
173pub struct DerivedPreview {
174    pub attack_power: f32,
175    pub spell_power: f32,
176    pub evasion: f32,
177    pub carry_mass_max: f32,
178    pub sight_range_m: f32,
179    pub fov_deg: f32,
180}
181
182/// Trained skill — internal 0–1000; UI uses tiers / display level.
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
184pub struct SkillProgress {
185    pub level: u16,
186    #[serde(default)]
187    pub last_trained_tick: u64,
188}
189
190impl Default for SkillProgress {
191    fn default() -> Self {
192        Self {
193            level: 0,
194            last_trained_tick: 0,
195        }
196    }
197}
198
199impl SkillProgress {
200    /// Mastery tier 0–10 (`plans/11` §10). Tier 0 = untrained; tier 1 begins at internal 100.
201    pub fn display_tier(&self) -> u16 {
202        (self.level / 100).min(10)
203    }
204}
205
206/// Core trained skills shipped at launch (`plans/11` §10).
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
208#[serde(default)]
209pub struct PlayerSkills {
210    pub logging: SkillProgress,
211    pub mining: SkillProgress,
212    pub evocation: SkillProgress,
213    pub swords: SkillProgress,
214    pub crafting: SkillProgress,
215    pub cartography: SkillProgress,
216}
217
218impl Default for PlayerSkills {
219    fn default() -> Self {
220        Self {
221            logging: SkillProgress::default(),
222            mining: SkillProgress::default(),
223            evocation: SkillProgress::default(),
224            swords: SkillProgress::default(),
225            crafting: SkillProgress::default(),
226            cartography: SkillProgress::default(),
227        }
228    }
229}
230
231/// Player health / resources (players only; omitted on other entities).
232#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
233pub struct PlayerVitals {
234    pub health: f32,
235    pub health_max: f32,
236    pub mana: f32,
237    pub mana_max: f32,
238    pub stamina: f32,
239    pub stamina_max: f32,
240    #[serde(default = "default_survival_pool_max")]
241    pub hunger: f32,
242    #[serde(default = "default_survival_pool_max")]
243    pub hunger_max: f32,
244    #[serde(default = "default_survival_pool_max")]
245    pub thirst: f32,
246    #[serde(default = "default_survival_pool_max")]
247    pub thirst_max: f32,
248    #[serde(default)]
249    pub coins: u32,
250    #[serde(default)]
251    pub deaths: u32,
252    #[serde(default)]
253    pub life_state: LifeState,
254}
255
256fn default_survival_pool_max() -> f32 {
257    100.0
258}
259
260impl Default for PlayerVitals {
261    fn default() -> Self {
262        Self::from_attributes(PrimaryAttributes::default())
263    }
264}
265
266impl PlayerVitals {
267    /// Build pool maximums from primary attributes (current pools filled to max).
268    ///
269    /// Uses **display** stats (internal ÷ 10) so pools scale intuitively with
270    /// progression, gear, and future enchant modifiers on attributes.
271    ///
272    /// Default attrs (all 500 → display 50): ~150 HP, ~100 stamina, ~145 mana.
273    pub fn from_attributes(attrs: PrimaryAttributes) -> Self {
274        let vit_d = PrimaryAttributes::display(attrs.vitality) as f32;
275        let int_d = PrimaryAttributes::display(attrs.intelligence) as f32;
276        let wis_d = PrimaryAttributes::display(attrs.wisdom) as f32;
277        let sta_d = PrimaryAttributes::display(attrs.stamina) as f32;
278
279        let health_max = 50.0 + vit_d * 2.0;
280        let stamina_max = 30.0 + sta_d * 1.4;
281        let mana_max = 25.0 + int_d * 1.1 + wis_d * 1.3;
282        let hunger_max = 100.0;
283        let thirst_max = 100.0;
284        Self {
285            health: health_max,
286            health_max,
287            mana: mana_max,
288            mana_max,
289            stamina: stamina_max,
290            stamina_max,
291            hunger: hunger_max,
292            hunger_max,
293            thirst: thirst_max,
294            thirst_max,
295            coins: 0,
296            deaths: 0,
297            life_state: LifeState::Alive,
298        }
299    }
300
301    /// Legacy pool maxima (pre-2026-07 pool rebalance) for migration scaling.
302    pub fn legacy_maxima(attrs: PrimaryAttributes) -> (f32, f32, f32) {
303        (
304            attrs.vitality as f32 / 5.0,
305            attrs.stamina as f32 / 5.0,
306            (attrs.intelligence as f32 + attrs.wisdom as f32) / 20.0,
307        )
308    }
309}
310
311/// Durable pool snapshot for Postgres (max values recomputed from attributes on load).
312#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
313#[serde(default)]
314pub struct StoredVitalsState {
315    pub health: f32,
316    pub mana: f32,
317    pub stamina: f32,
318    pub hunger: f32,
319    pub thirst: f32,
320    pub coins: u32,
321    pub deaths: u32,
322    pub life_state: LifeState,
323}
324
325impl StoredVitalsState {
326    pub fn from_live(v: &PlayerVitals) -> Self {
327        Self {
328            health: v.health,
329            mana: v.mana,
330            stamina: v.stamina,
331            hunger: v.hunger,
332            thirst: v.thirst,
333            coins: v.coins,
334            deaths: v.deaths,
335            life_state: v.life_state,
336        }
337    }
338
339    /// Empty `{}` JSON from a new character row — not a real gameplay snapshot.
340    pub fn is_pristine(&self) -> bool {
341        self.health == 0.0
342            && self.mana == 0.0
343            && self.stamina == 0.0
344            && self.hunger == 0.0
345            && self.thirst == 0.0
346            && self.coins == 0
347            && self.deaths == 0
348            && self.life_state == LifeState::Alive
349    }
350
351    pub fn apply_to(self, attrs: PrimaryAttributes) -> PlayerVitals {
352        if self.is_pristine() {
353            return PlayerVitals::from_attributes(attrs);
354        }
355        let fresh = PlayerVitals::from_attributes(attrs);
356        let (legacy_hp, legacy_sta, legacy_mana) = PlayerVitals::legacy_maxima(attrs);
357
358        let scale = |current: f32, legacy_max: f32, new_max: f32| {
359            if legacy_max > 0.0
360                && new_max > legacy_max * 1.05
361                && current >= legacy_max * 0.95
362            {
363                let ratio = (current / legacy_max).clamp(0.0, 1.0);
364                (new_max * ratio).min(new_max)
365            } else {
366                current.min(new_max)
367            }
368        };
369
370        let mut v = fresh;
371        v.health = scale(self.health, legacy_hp, fresh.health_max);
372        v.mana = scale(self.mana, legacy_mana, fresh.mana_max);
373        v.stamina = scale(self.stamina, legacy_sta, fresh.stamina_max);
374        v.hunger = self.hunger.min(v.hunger_max);
375        v.thirst = self.thirst.min(v.thirst_max);
376        v.coins = self.coins;
377        v.deaths = self.deaths;
378        v.life_state = self.life_state;
379        v
380    }
381}
382
383impl Default for StoredVitalsState {
384    fn default() -> Self {
385        Self::from_live(&PlayerVitals::default())
386    }
387}
388
389/// Saved ability rotation preset (`plans/26` §C3).
390#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
391pub struct RotationPreset {
392    pub id: String,
393    pub label: String,
394    #[serde(default)]
395    pub abilities: Vec<String>,
396}
397
398impl RotationPreset {
399    pub fn melee_default(ability_id: impl Into<String>) -> Self {
400        let id = ability_id.into();
401        Self {
402            id: "melee".into(),
403            label: "Melee".into(),
404            abilities: vec![id],
405        }
406    }
407}
408
409/// Durable combat state (`plans/12` §4.1) — target by wildlife instance id, not entity id.
410#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
411pub struct StoredTargetSlot {
412    pub instance_id: Option<String>,
413    #[serde(default)]
414    pub preset_id: Option<String>,
415    #[serde(default)]
416    pub rotation_index: u32,
417    #[serde(default)]
418    pub auto_enabled: bool,
419}
420
421#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
422#[serde(default)]
423pub struct StoredCombatProfile {
424    /// Wildlife `instance_id` (e.g. `meadow-rabbits-0`), stable across worker restarts.
425    pub combat_target_instance_id: Option<String>,
426    pub in_combat: bool,
427    pub last_combat_tick: u64,
428    pub last_attack_tick: u64,
429    pub cooldowns_until_tick: BTreeMap<String, u64>,
430    #[serde(default = "default_auto_attack")]
431    pub auto_attack_enabled: bool,
432    /// Equipped mainhand weapon template id (`plans/26` §C2b).
433    #[serde(default)]
434    pub mainhand_template_id: Option<String>,
435    /// Equipped body-slot items (backpack, belt, future armor), nested contents included
436    /// (e.g. pouches clipped onto a worn belt). At most one entry per `BodySlot`.
437    #[serde(default)]
438    pub worn: Vec<(BodySlot, ItemStack)>,
439    /// Player-authored rotation library (`plans/26` §C3).
440    #[serde(default)]
441    pub rotation_presets: Vec<RotationPreset>,
442    /// Per-target slot state (T1/T2).
443    #[serde(default)]
444    pub target_slots: Vec<StoredTargetSlot>,
445}
446
447fn default_auto_attack() -> bool {
448    true
449}
450
451impl Default for StoredCombatProfile {
452    fn default() -> Self {
453        Self {
454            combat_target_instance_id: None,
455            in_combat: false,
456            last_combat_tick: 0,
457            last_attack_tick: 0,
458            cooldowns_until_tick: BTreeMap::new(),
459            auto_attack_enabled: true,
460            mainhand_template_id: None,
461            worn: Vec::new(),
462            rotation_presets: Vec::new(),
463            target_slots: Vec::new(),
464        }
465    }
466}
467
468#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
469pub struct EntityState {
470    pub id: EntityId,
471    pub transform: Transform,
472    /// Character / display name (first letter used as map glyph for other players).
473    #[serde(default)]
474    pub label: String,
475    #[serde(default)]
476    pub vitals: Option<PlayerVitals>,
477    /// Primary attributes (local player / inspect).
478    #[serde(default)]
479    pub attributes: Option<PrimaryAttributes>,
480    #[serde(default)]
481    pub skills: Option<PlayerSkills>,
482    /// When inside a building interior (players only).
483    #[serde(default)]
484    pub inside_building: Option<String>,
485}
486
487/// Nearby voice chat vs magical long-range (requires whisper-stone item).
488#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
489#[serde(rename_all = "snake_case")]
490pub enum ChatChannel {
491    Nearby,
492    WhisperStone,
493}
494
495#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
496pub struct ChatMessage {
497    pub channel: ChatChannel,
498    pub from_entity: EntityId,
499    pub from_name: String,
500    pub text: String,
501    pub tick: Tick,
502}
503
504/// Client → server gameplay input (reliable, sequenced per entity).
505#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
506pub enum Intent {
507    Move {
508        entity_id: EntityId,
509        forward: f32,
510        strafe: f32,
511        /// Climb (+) or descend (−) on z axis (m/s intent).
512        #[serde(default)]
513        vertical: f32,
514        /// Sprint multiplier when stamina allows.
515        #[serde(default)]
516        sprint: bool,
517        seq: Seq,
518    },
519    Stop {
520        entity_id: EntityId,
521        seq: Seq,
522    },
523    Harvest {
524        entity_id: EntityId,
525        node_id: String,
526        seq: Seq,
527    },
528    Use {
529        entity_id: EntityId,
530        template_id: String,
531        seq: Seq,
532    },
533    Say {
534        entity_id: EntityId,
535        channel: ChatChannel,
536        text: String,
537        seq: Seq,
538    },
539    /// Start a blueprint craft (timed, like harvest).
540    Craft {
541        entity_id: EntityId,
542        blueprint_id: String,
543        /// Batches to run back-to-back; `None` crafts as many as materials/stamina allow.
544        #[serde(default)]
545        count: Option<u32>,
546        seq: Seq,
547    },
548    /// Door, NPC, enter/exit building.
549    Interact {
550        entity_id: EntityId,
551        target_id: String,
552        seq: Seq,
553    },
554    /// Dev / test: apply damage to self (co-op testing).
555    TestDamage {
556        entity_id: EntityId,
557        amount: f32,
558        seq: Seq,
559    },
560    /// Slot-1 combat target (`plans/12` alias).
561    SetTarget {
562        entity_id: EntityId,
563        target_id: EntityId,
564        seq: Seq,
565    },
566    /// Target slot assignment (`plans/12` §5.3). Slot 1 aliases `SetTarget`.
567    SetTargetSlot {
568        entity_id: EntityId,
569        slot_index: u8,
570        target_id: EntityId,
571        seq: Seq,
572    },
573    ClearTarget {
574        entity_id: EntityId,
575        seq: Seq,
576    },
577    ClearTargetSlot {
578        entity_id: EntityId,
579        slot_index: u8,
580        seq: Seq,
581    },
582    /// Toggle per-slot auto-attack (`plans/12` §4.2).
583    SetAutoAttack {
584        entity_id: EntityId,
585        slot_index: u8,
586        enabled: bool,
587        seq: Seq,
588    },
589    /// Melee attack — uses `target_id` or the player's current target.
590    Attack {
591        entity_id: EntityId,
592        #[serde(default)]
593        target_id: Option<EntityId>,
594        #[serde(default)]
595        weapon_slot: Option<u32>,
596        seq: Seq,
597    },
598    /// Pick up a ground loot pile (nearest in range when `drop_id` omitted).
599    Pickup {
600        entity_id: EntityId,
601        #[serde(default)]
602        drop_id: Option<String>,
603        seq: Seq,
604    },
605    /// Cast a spell or use a non-weapon ability template (`plans/24` §4.3b).
606    Cast {
607        entity_id: EntityId,
608        ability_id: String,
609        target_id: EntityId,
610        seq: Seq,
611    },
612    /// Bind an ability to a target slot action bar (`plans/12` §4.2).
613    BindActionSlot {
614        entity_id: EntityId,
615        slot_index: u8,
616        ability_id: String,
617        #[serde(default = "default_auto_attack")]
618        auto_enabled: bool,
619        seq: Seq,
620    },
621    /// Fire a bound consumable or ability from a slot (`plans/24` §4.3c).
622    UseActionSlot {
623        entity_id: EntityId,
624        slot_index: u8,
625        seq: Seq,
626    },
627    /// Dodge — brief i-frames, stamina cost (`plans/24` §4.3c).
628    Dodge {
629        entity_id: EntityId,
630        seq: Seq,
631    },
632    /// Lunge — burst forward in movement/facing direction, higher stamina cost.
633    Lunge {
634        entity_id: EntityId,
635        /// Last movement forward axis when idle (−1..1).
636        #[serde(default)]
637        forward: f32,
638        /// Last movement strafe axis when idle (−1..1).
639        #[serde(default)]
640        strafe: f32,
641        seq: Seq,
642    },
643    /// Block — frontal mitigation while held (`plans/24` §4.3c).
644    Block {
645        entity_id: EntityId,
646        #[serde(default = "default_block_enabled")]
647        enabled: bool,
648        seq: Seq,
649    },
650    /// Equip or clear mainhand weapon (`plans/26` §C2b). `None` unequips.
651    EquipMainhand {
652        entity_id: EntityId,
653        #[serde(default)]
654        template_id: Option<String>,
655        seq: Seq,
656    },
657    /// Equip a wearable (container, belt, or armor piece) to a body slot, or clear the
658    /// slot when `instance_id` is None.
659    EquipWorn {
660        entity_id: EntityId,
661        slot: BodySlot,
662        #[serde(default)]
663        instance_id: Option<Uuid>,
664        seq: Seq,
665    },
666    /// Move an item instance between root / worn / placed container inventories.
667    MoveItem {
668        entity_id: EntityId,
669        item_instance_id: Uuid,
670        from: InventoryLocation,
671        to: InventoryLocation,
672        /// When moving into a container location, nest under this parent instance (None = container root).
673        #[serde(default)]
674        to_parent_instance_id: Option<Uuid>,
675        /// Units to move; `None` moves the whole stack. Server clamps to volume/carry limits.
676        #[serde(default)]
677        quantity: Option<u32>,
678        seq: Seq,
679    },
680    /// Place a placeable container from inventory onto the ground at the player's feet.
681    PlaceContainer {
682        entity_id: EntityId,
683        item_instance_id: Uuid,
684        seq: Seq,
685    },
686    /// Pick up a placed container (with contents) into inventory.
687    PickupContainer {
688        entity_id: EntityId,
689        container_id: String,
690        seq: Seq,
691    },
692    /// Lock or unlock a worn or placed container (requires matching key when locking/unlocking).
693    SetContainerLocked {
694        entity_id: EntityId,
695        /// Worn slot or placed container id encoded as location.
696        location: InventoryLocation,
697        locked: bool,
698        seq: Seq,
699    },
700    /// Drop a non-container item on the ground at the player's feet.
701    DropItem {
702        entity_id: EntityId,
703        item_instance_id: Uuid,
704        from: InventoryLocation,
705        seq: Seq,
706    },
707    /// Set a custom display name on a container instance (chest, pouch, backpack).
708    RenameContainer {
709        entity_id: EntityId,
710        item_instance_id: Uuid,
711        location: InventoryLocation,
712        name: String,
713        seq: Seq,
714    },
715    /// Create or update a rotation preset in the player's library.
716    UpsertRotationPreset {
717        entity_id: EntityId,
718        preset: RotationPreset,
719        seq: Seq,
720    },
721    /// Remove a rotation preset from the library.
722    DeleteRotationPreset {
723        entity_id: EntityId,
724        preset_id: String,
725        seq: Seq,
726    },
727    /// Assign a library preset to target slot T1/T2.
728    AssignSlotPreset {
729        entity_id: EntityId,
730        slot_index: u8,
731        preset_id: String,
732        seq: Seq,
733    },
734    /// Fire the next ready ability in a slot's rotation (manual step).
735    AdvanceRotation {
736        entity_id: EntityId,
737        slot_index: u8,
738        seq: Seq,
739    },
740}
741
742fn default_block_enabled() -> bool {
743    true
744}
745
746/// Observer combat HUD (local player only).
747#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
748pub struct CombatTargetHud {
749    pub entity_id: EntityId,
750    #[serde(default)]
751    pub label: String,
752    #[serde(default)]
753    pub level: u32,
754    pub health: f32,
755    pub health_max: f32,
756    #[serde(default)]
757    pub life_state: LifeState,
758    #[serde(default)]
759    pub distance_m: f32,
760}
761
762/// Active spell cast channel progress.
763#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
764pub struct CastProgressHud {
765    #[serde(default)]
766    pub ability_id: String,
767    #[serde(default)]
768    pub ability_label: String,
769    #[serde(default)]
770    pub ticks_remaining: u64,
771    #[serde(default)]
772    pub ticks_total: u64,
773}
774
775/// Cooldown state for a combat ability shown on the action bar.
776#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
777pub struct AbilityCooldownHud {
778    #[serde(default)]
779    pub ability_id: String,
780    #[serde(default)]
781    pub label: String,
782    #[serde(default)]
783    pub cd_ticks: u64,
784    #[serde(default)]
785    pub cd_total_ticks: u64,
786}
787
788/// One combat target slot in the observer HUD.
789#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
790pub struct CombatSlotHud {
791    pub slot_index: u8,
792    #[serde(default)]
793    pub target_entity_id: Option<EntityId>,
794    #[serde(default)]
795    pub target_label: Option<String>,
796    #[serde(default)]
797    pub target: Option<CombatTargetHud>,
798    #[serde(default)]
799    pub preset_id: Option<String>,
800    #[serde(default)]
801    pub preset_label: Option<String>,
802    #[serde(default)]
803    pub rotation: Vec<String>,
804    #[serde(default)]
805    pub rotation_index: u32,
806    #[serde(default)]
807    pub next_ability_id: Option<String>,
808    #[serde(default)]
809    pub auto_enabled: bool,
810}
811
812/// Observer combat HUD (local player only).
813#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
814pub struct CombatHud {
815    pub in_combat: bool,
816    /// T1 auto rotation (legacy field mirrors slot 1).
817    pub auto_attack: bool,
818    pub has_los: bool,
819    pub attack_cd_ticks: u64,
820    #[serde(default)]
821    pub ability_id: String,
822    #[serde(default)]
823    pub target_entity_id: Option<EntityId>,
824    #[serde(default)]
825    pub target_label: Option<String>,
826    #[serde(default)]
827    pub max_target_slots: u8,
828    #[serde(default)]
829    pub slots: Vec<CombatSlotHud>,
830    #[serde(default)]
831    pub rotation_presets: Vec<RotationPreset>,
832    #[serde(default)]
833    pub gcd_ticks: u64,
834    #[serde(default)]
835    pub mainhand_template_id: Option<String>,
836    #[serde(default)]
837    pub mainhand_label: Option<String>,
838    /// Equipped body-slot items (backpack, belt w/ clipped pouches, future armor).
839    #[serde(default)]
840    pub worn: Vec<(BodySlot, ItemStack)>,
841    #[serde(default)]
842    pub carry_mass: f32,
843    #[serde(default)]
844    pub carry_mass_max: f32,
845    #[serde(default)]
846    pub encumbrance: EncumbranceState,
847    #[serde(default)]
848    pub target: Option<CombatTargetHud>,
849    #[serde(default)]
850    pub cast: Option<CastProgressHud>,
851    #[serde(default)]
852    pub ability_cooldowns: Vec<AbilityCooldownHud>,
853    #[serde(default)]
854    pub blocking_active: bool,
855}
856
857/// Server → client AOI-filtered entity updates for one sim tick.
858#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
859pub struct TickDelta {
860    pub tick: Tick,
861    pub entities: Vec<EntityState>,
862    #[serde(default)]
863    pub resource_nodes: Vec<ResourceNodeView>,
864    #[serde(default)]
865    pub buildings: Vec<BuildingView>,
866    #[serde(default)]
867    pub doors: Vec<DoorView>,
868    #[serde(default)]
869    pub npcs: Vec<NpcView>,
870    /// Observer inventory stacks (template → qty).
871    #[serde(default)]
872    pub inventory: Vec<ItemStack>,
873    #[serde(default)]
874    pub blueprints: Vec<BlueprintView>,
875    #[serde(default)]
876    pub world_clock: WorldClock,
877    #[serde(default)]
878    pub ground_drops: Vec<GroundDropView>,
879    #[serde(default)]
880    pub placed_containers: Vec<PlacedContainerView>,
881    #[serde(default)]
882    pub combat: Option<CombatHud>,
883}
884
885/// Loot pile on the ground (AOI-filtered).
886#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
887pub struct GroundDropView {
888    pub id: String,
889    pub template_id: String,
890    pub quantity: u32,
891    pub x: f32,
892    pub y: f32,
893    pub z: f32,
894}
895
896/// Full state on region enter or reconnect.
897#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
898pub struct Snapshot {
899    pub tick: Tick,
900    pub chunk_rev: u64,
901    /// Bumped when blueprints, catalog, segment, or settings reload.
902    #[serde(default)]
903    pub content_rev: u64,
904    pub entities: Vec<EntityState>,
905    #[serde(default)]
906    pub resource_nodes: Vec<ResourceNodeView>,
907    /// Segment play area width in meters (for HUD).
908    #[serde(default)]
909    pub world_width_m: f32,
910    #[serde(default)]
911    pub world_height_m: f32,
912    #[serde(default)]
913    pub buildings: Vec<BuildingView>,
914    #[serde(default)]
915    pub doors: Vec<DoorView>,
916    #[serde(default)]
917    pub npcs: Vec<NpcView>,
918    #[serde(default)]
919    pub inventory: Vec<ItemStack>,
920    #[serde(default)]
921    pub blueprints: Vec<BlueprintView>,
922    #[serde(default)]
923    pub world_clock: WorldClock,
924    #[serde(default)]
925    pub terrain_zones: Vec<TerrainZoneView>,
926    #[serde(default)]
927    pub ground_drops: Vec<GroundDropView>,
928    #[serde(default)]
929    pub placed_containers: Vec<PlacedContainerView>,
930    #[serde(default)]
931    pub combat: Option<CombatHud>,
932}
933
934/// Harvestable node visible to clients.
935#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
936pub struct ResourceNodeView {
937    pub id: String,
938    pub label: String,
939    pub x: f32,
940    pub y: f32,
941    pub z: f32,
942    pub item_template: String,
943    #[serde(default = "default_node_state")]
944    pub state: ResourceNodeState,
945    /// When true, players cannot walk through this node while available/harvesting.
946    #[serde(default = "default_blocking_view")]
947    pub blocking: bool,
948    /// Collision radius for pathfinding (meters).
949    #[serde(default = "default_blocking_radius_view")]
950    pub blocking_radius_m: f32,
951}
952
953fn default_blocking_radius_view() -> f32 {
954    0.8
955}
956
957fn default_blocking_view() -> bool {
958    true
959}
960
961#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
962#[serde(rename_all = "snake_case")]
963pub enum ResourceNodeState {
964    Available,
965    Harvesting,
966    Cooldown,
967}
968
969fn default_node_state() -> ResourceNodeState {
970    ResourceNodeState::Available
971}
972
973#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
974pub struct ItemStack {
975    pub template_id: String,
976    pub quantity: u32,
977    /// Stable instance id — preserved across checkpoint/sync when set.
978    #[serde(default)]
979    pub item_instance_id: Option<Uuid>,
980    /// Per-instance metadata (stat rolls, soul-bind, lock ids, etc.).
981    #[serde(default)]
982    pub props: BTreeMap<String, String>,
983    /// Nested contents when this stack is a container instance.
984    #[serde(default)]
985    pub contents: Vec<ItemStack>,
986    /// From item catalog when sent on wire (display only).
987    #[serde(default)]
988    pub display_name: Option<String>,
989    /// From item catalog when sent on wire (`weapon`, `consumable`, …).
990    #[serde(default)]
991    pub category: Option<String>,
992    /// Per-unit mass in kg from catalog (display / move UX).
993    #[serde(default)]
994    pub base_mass: Option<f32>,
995    /// Per-unit volume from catalog (display / move UX).
996    #[serde(default)]
997    pub base_volume: Option<f32>,
998    /// Container capacity when this stack is a container template.
999    #[serde(default)]
1000    pub capacity_volume: Option<f32>,
1001    /// Whether the template stacks in inventory (from catalog).
1002    #[serde(default)]
1003    pub stackable: Option<bool>,
1004}
1005
1006impl ItemStack {
1007    pub fn simple(template_id: impl Into<String>, quantity: u32) -> Self {
1008        Self {
1009            template_id: template_id.into(),
1010            quantity,
1011            item_instance_id: None,
1012            props: BTreeMap::new(),
1013            contents: Vec::new(),
1014            display_name: None,
1015            category: None,
1016            base_mass: None,
1017            base_volume: None,
1018            capacity_volume: None,
1019            stackable: None,
1020        }
1021    }
1022}
1023
1024/// Carry encumbrance band (`plans/08` §3.2).
1025#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1026#[serde(rename_all = "snake_case")]
1027pub enum EncumbranceState {
1028    #[default]
1029    Light,
1030    Heavy,
1031    Over,
1032}
1033
1034/// Body region a wearable item occupies — at most one item equipped per slot.
1035/// `Head`/`Body`/`Arms`/`Legs`/`Feet` are reserved for future armor (`plans/11`); only
1036/// `Back` (backpack) and `Waist` (belt) have equippable items today (`plans/08` §4.1).
1037#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)]
1038#[serde(rename_all = "snake_case")]
1039pub enum BodySlot {
1040    Head,
1041    Body,
1042    Arms,
1043    Legs,
1044    Feet,
1045    Back,
1046    Waist,
1047}
1048
1049/// Where an item lives for `MoveItem` / open-container UX.
1050#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1051#[serde(rename_all = "snake_case")]
1052pub enum InventoryLocation {
1053    /// Loose on-person inventory (not inside a worn/placed container).
1054    Root,
1055    /// Inside a worn item (backpack contents, or a pouch clipped onto a worn belt).
1056    Worn {
1057        slot: BodySlot,
1058    },
1059    /// Inside a world-placed container.
1060    Placed {
1061        container_id: String,
1062    },
1063}
1064
1065/// AOI view of a placeable chest on the map.
1066#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1067pub struct PlacedContainerView {
1068    pub id: String,
1069    pub template_id: String,
1070    pub display_name: String,
1071    pub x: f32,
1072    pub y: f32,
1073    pub z: f32,
1074    pub locked: bool,
1075    /// Observer can open (unlocked, or holds matching key).
1076    #[serde(default)]
1077    pub accessible: bool,
1078    #[serde(default)]
1079    pub owner_character_id: Option<Uuid>,
1080    /// Nested contents when `accessible` (empty when locked without key).
1081    #[serde(default)]
1082    pub contents: Vec<ItemStack>,
1083    /// Container instance id (for MoveItem parent targeting).
1084    #[serde(default)]
1085    pub item_instance_id: Option<Uuid>,
1086}
1087
1088#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1089pub struct BlueprintIngredientView {
1090    pub template_id: String,
1091    pub quantity: u32,
1092    /// Always serialize — `true` is not bool::default() so postcard keeps it; explicit for clarity.
1093    pub consumed: bool,
1094}
1095
1096#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1097pub struct ToolRequirementView {
1098    pub item: String,
1099    /// Always serialize — postcard omits `false` by default, which breaks roundtrip without explicit value.
1100    pub consumed: bool,
1101}
1102
1103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1104pub struct SkillRequirementView {
1105    pub skill: String,
1106    pub level: u32,
1107}
1108
1109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1110pub struct BlueprintView {
1111    pub id: String,
1112    pub label: String,
1113    pub output: String,
1114    pub output_qty: u32,
1115    pub craft_ticks: u32,
1116    pub inputs: Vec<BlueprintIngredientView>,
1117    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
1118    #[serde(default)]
1119    pub station: Option<String>,
1120    #[serde(default)]
1121    pub category: Option<String>,
1122    #[serde(default)]
1123    pub required_tools: Vec<ToolRequirementView>,
1124    #[serde(default)]
1125    pub skill: Option<SkillRequirementView>,
1126    #[serde(default)]
1127    pub failure_chance: f32,
1128}
1129
1130/// Terrain overlay from segment YAML (`terrain_zones`).
1131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1132#[serde(rename_all = "snake_case")]
1133pub enum TerrainKindView {
1134    #[default]
1135    Grass,
1136    Hill,
1137    Bog,
1138    ShallowWater,
1139    DeepWater,
1140}
1141
1142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1143pub struct TerrainZoneView {
1144    pub id: String,
1145    pub x0: f32,
1146    pub y0: f32,
1147    pub x1: f32,
1148    pub y1: f32,
1149    #[serde(default)]
1150    pub kind: TerrainKindView,
1151}
1152
1153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1154pub struct BuildingView {
1155    pub id: String,
1156    pub label: String,
1157    pub x: f32,
1158    pub y: f32,
1159    pub width_m: f32,
1160    pub depth_m: f32,
1161    /// Interior map origin (for wall rendering when player is inside).
1162    #[serde(default)]
1163    pub interior_origin_x: f32,
1164    #[serde(default)]
1165    pub interior_origin_y: f32,
1166    #[serde(default)]
1167    pub tags: Vec<String>,
1168}
1169
1170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1171pub struct DoorView {
1172    pub id: String,
1173    pub building_id: String,
1174    pub x: f32,
1175    pub y: f32,
1176    #[serde(default)]
1177    pub open: bool,
1178    #[serde(default)]
1179    pub is_exit: bool,
1180}
1181
1182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1183pub struct NpcView {
1184    pub id: String,
1185    pub label: String,
1186    pub role: String,
1187    pub x: f32,
1188    pub y: f32,
1189    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
1190    #[serde(default)]
1191    pub building_id: Option<String>,
1192    /// Authoritative sim entity for wildlife / combat targets.
1193    #[serde(default)]
1194    pub entity_id: Option<EntityId>,
1195    #[serde(default)]
1196    pub life_state: Option<LifeState>,
1197    #[serde(default)]
1198    pub hp_pct: Option<f32>,
1199}
1200
1201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1202pub struct UseResult {
1203    pub template_id: String,
1204    pub hunger_restored: f32,
1205    pub thirst_restored: f32,
1206}
1207
1208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1209pub struct CraftResult {
1210    pub blueprint_id: String,
1211    pub outputs: Vec<ItemStack>,
1212    pub consumed: Vec<ItemStack>,
1213    /// 1-based index within the submitted batch (1 when not batching).
1214    #[serde(default = "default_one")]
1215    pub batch_index: u32,
1216    /// Total crafts requested in this batch (1 when not batching).
1217    #[serde(default = "default_one")]
1218    pub batch_total: u32,
1219}
1220
1221fn default_one() -> u32 {
1222    1
1223}
1224
1225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1226pub struct DeathNotice {
1227    pub entity_id: EntityId,
1228    pub respawn_x: f32,
1229    pub respawn_y: f32,
1230    pub message: String,
1231}
1232
1233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1234pub struct InteractionNotice {
1235    pub target_id: String,
1236    pub message: String,
1237    #[serde(default)]
1238    pub coins_delta: i32,
1239    #[serde(default)]
1240    pub inventory_delta: Vec<ItemStack>,
1241}
1242
1243#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1244pub struct HarvestResult {
1245    pub node_id: String,
1246    /// Stack quantity granted (not one-node-one-instance).
1247    pub quantity: u32,
1248    pub item_template: String,
1249    /// Optional DB row id when persisted to control plane.
1250    /// Postcard always serializes optional fields (no `skip_serializing_if`) so decode stays aligned.
1251    #[serde(default)]
1252    pub item_instance_id: Option<Uuid>,
1253}
1254
1255/// Every on-wire payload is wrapped for versioning and codec uniformity.
1256#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1257pub struct Envelope<T> {
1258    pub protocol_version: u16,
1259    pub payload: T,
1260}
1261
1262impl<T> Envelope<T> {
1263    pub fn new(payload: T) -> Self {
1264        Self {
1265            protocol_version: crate::PROTOCOL_VERSION,
1266            payload,
1267        }
1268    }
1269}
1270
1271/// Session handshake after transport connect.
1272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1273pub struct Hello {
1274    pub client_name: String,
1275    pub protocol_version: u16,
1276    #[serde(default)]
1277    pub auth: AuthCredential,
1278    /// Required for session auth; embedded in `ApiToken` variant otherwise.
1279    #[serde(default)]
1280    pub character_id: Option<Uuid>,
1281}
1282
1283/// How the client authenticates to the game gateway.
1284/// Uses default serde enum encoding (postcard-compatible; not internally tagged).
1285#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1286#[serde(rename_all = "snake_case")]
1287pub enum AuthCredential {
1288    DevLocal,
1289    Session { token: String },
1290    ApiToken { token: String, character_id: Uuid },
1291}
1292
1293impl Default for AuthCredential {
1294    fn default() -> Self {
1295        Self::DevLocal
1296    }
1297}
1298
1299#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1300pub struct Welcome {
1301    pub session_id: SessionId,
1302    pub entity_id: EntityId,
1303    pub snapshot: Snapshot,
1304}
1305
1306#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1307pub enum ServerMessage {
1308    Welcome(Welcome),
1309    /// Static world layers refreshed (blueprints, catalog, map, terrain) — no client restart.
1310    ContentUpdated(Snapshot),
1311    Tick(TickDelta),
1312    IntentAck {
1313        entity_id: EntityId,
1314        seq: Seq,
1315        tick: Tick,
1316    },
1317    Chat(ChatMessage),
1318    HarvestResult(HarvestResult),
1319    UseResult(UseResult),
1320    CraftResult(CraftResult),
1321    Death(DeathNotice),
1322    Interaction(InteractionNotice),
1323}
1324
1325#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1326pub enum ClientMessage {
1327    Hello(Hello),
1328    Intent(Intent),
1329    Disconnect,
1330}
1331
1332#[cfg(test)]
1333mod tests {
1334    use super::*;
1335
1336    #[test]
1337    fn pristine_vitals_state_yields_full_pools() {
1338        let attrs = PrimaryAttributes::default();
1339        let vitals = StoredVitalsState::default().apply_to(attrs);
1340        assert!(vitals.health > 0.0);
1341        assert_eq!(vitals.health, vitals.health_max);
1342        assert!((vitals.mana_max - 145.0).abs() < 0.01);
1343    }
1344
1345    #[test]
1346    fn saved_vitals_scale_when_pool_max_increases() {
1347        let mut attrs = PrimaryAttributes::default();
1348        attrs.intelligence = 140;
1349        attrs.wisdom = 140;
1350        let saved = StoredVitalsState {
1351            health: 100.0,
1352            mana: 14.0,
1353            stamina: 100.0,
1354            ..StoredVitalsState::default()
1355        };
1356        let vitals = saved.apply_to(attrs);
1357        assert!(vitals.mana_max > 55.0);
1358        assert!(
1359            (vitals.mana - vitals.mana_max).abs() < 0.01,
1360            "full legacy mana bar migrates to full new bar"
1361        );
1362    }
1363
1364    #[test]
1365    fn empty_vitals_state_is_pristine() {
1366        let pristine = StoredVitalsState {
1367            health: 0.0,
1368            mana: 0.0,
1369            stamina: 0.0,
1370            hunger: 0.0,
1371            thirst: 0.0,
1372            coins: 0,
1373            deaths: 0,
1374            life_state: LifeState::Alive,
1375        };
1376        assert!(pristine.is_pristine());
1377        let vitals = pristine.apply_to(PrimaryAttributes::default());
1378        assert!(vitals.health > 0.0);
1379    }
1380
1381    #[test]
1382    fn stored_vitals_roundtrip_preserves_partial_pools() {
1383        let attrs = PrimaryAttributes::default();
1384        let mut live = PlayerVitals::from_attributes(attrs);
1385        live.health = 42.0;
1386        live.hunger = 77.0;
1387        live.deaths = 2;
1388        let stored = StoredVitalsState::from_live(&live);
1389        let restored = stored.apply_to(attrs);
1390        assert!((restored.health - 42.0).abs() < 0.01, "partial HP below old cap stays absolute");
1391        assert_eq!(restored.hunger, 77.0);
1392        assert_eq!(restored.deaths, 2);
1393    }
1394
1395    #[test]
1396    fn skill_tiers_start_at_zero() {
1397        let skill = SkillProgress::default();
1398        assert_eq!(skill.level, 0);
1399        assert_eq!(skill.display_tier(), 0);
1400        let trained = SkillProgress {
1401            level: 250,
1402            last_trained_tick: 1,
1403        };
1404        assert_eq!(trained.display_tier(), 2);
1405    }
1406}