Skip to main content

flatland_client_lib/
character_sheet.rs

1//! Character sheet model for the `i` stats overlay (gfx + TUI).
2
3use flatland_protocol::{
4    EncumbranceState, LifeState, PrimaryAttributes, ProgressionCurve, PRIMARY_STAT_ROWS, SKILL_ROWS,
5};
6
7use crate::{body_slot_label, GameState};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum SheetSync {
11    /// XP pools on the wire — bars are authoritative.
12    Full,
13    /// Attributes/skills only; reconnect or server update needed.
14    MissingXpPools,
15}
16
17#[derive(Debug, Clone)]
18pub struct CharacterSheet {
19    pub sync: SheetSync,
20    pub name: String,
21    pub position: (f32, f32, f32),
22    pub inside_building: Option<String>,
23    pub life: String,
24    pub deaths: u32,
25    pub money: String,
26    pub attributes: Vec<AttributeRow>,
27    pub skills: Vec<SkillRow>,
28    /// Per-ability mastery (use-based tiers).
29    pub ability_mastery: Vec<AbilityMasteryRow>,
30    pub derived_attack: f32,
31    pub derived_spell: f32,
32    pub derived_evasion: f32,
33    pub derived_carry_kg: f32,
34    pub derived_sight_m: f32,
35    pub derived_fov_deg: f32,
36    pub pools: Vec<PoolRow>,
37    pub carry_mass: f32,
38    pub carry_mass_max: f32,
39    pub encumbrance: &'static str,
40    /// Effective walk speed (m/s) after encumbrance and DEX.
41    pub move_speed_mps: f32,
42    /// Load multiplier on walk (`1.0` = unencumbered).
43    pub move_speed_mult: f32,
44    pub mainhand: String,
45    pub offhand: String,
46    pub defense_summary: Option<String>,
47    pub worn: Vec<(String, String)>,
48    /// Active entity statuses (buffs/debuffs) for the character sheet.
49    pub active_statuses: Vec<String>,
50    pub keychain_count: usize,
51    pub in_combat: bool,
52    pub auto_attack: bool,
53    pub blocking: bool,
54    pub target_slots: u8,
55    pub has_los: bool,
56    pub target_label: Option<String>,
57    pub weapon_ability: Option<String>,
58    pub rotation_summary: Option<String>,
59    /// Learned combat abilities (friendly labels; unarmed always included after migrate).
60    pub learned_abilities: Vec<String>,
61    pub blueprint_count: usize,
62    pub footer_hints: Vec<String>,
63}
64
65impl CharacterSheet {
66    /// Player-facing walk speed (`3.2 m/s` plus load % when encumbered).
67    pub fn move_speed_display(&self) -> String {
68        if self.move_speed_mps <= 0.05 {
69            return "—".into();
70        }
71        let load = if self.move_speed_mult > 0.05 {
72            self.move_speed_mult
73        } else {
74            1.0
75        };
76        if (load - 1.0).abs() > 0.02 {
77            format!("{:.1} m/s ({:.0}% walk)", self.move_speed_mps, load * 100.0)
78        } else {
79            format!("{:.1} m/s", self.move_speed_mps)
80        }
81    }
82}
83
84#[derive(Debug, Clone)]
85pub struct AttributeRow {
86    pub label: &'static str,
87    pub level: u16,
88    pub next_level: u16,
89    pub progress: f32,
90    pub xp_into_band: f64,
91    pub xp_band_size: f64,
92    pub xp_to_next: f64,
93    pub pool_xp: f64,
94    pub above_baseline: f64,
95}
96
97#[derive(Debug, Clone)]
98pub struct SkillRow {
99    pub name: &'static str,
100    pub tier: u16,
101    pub next_tier: u16,
102    pub progress: f32,
103    pub xp_into_band: f64,
104    pub xp_band_size: f64,
105    pub xp_to_next: f64,
106    pub pool_xp: f64,
107}
108
109#[derive(Debug, Clone)]
110pub struct AbilityMasteryRow {
111    pub label: String,
112    pub ability_id: String,
113    pub tier: u16,
114    pub next_tier: u16,
115    pub progress: f32,
116    pub xp_into_band: f64,
117    pub xp_band_size: f64,
118    pub xp_to_next: f64,
119    pub pool_xp: f64,
120}
121
122#[derive(Debug, Clone)]
123pub struct PoolRow {
124    pub label: &'static str,
125    pub current: f32,
126    pub max: f32,
127}
128
129pub fn build_character_sheet(state: &GameState) -> CharacterSheet {
130    let curve = state.progression_curve.unwrap_or_default();
131    let bootstrap_primary = curve.xp_for_display_level(curve.baseline_display as f64);
132    let player = state.player.as_ref();
133    let attrs = player.and_then(|p| p.attributes);
134    let skills = player.and_then(|p| p.skills.as_ref());
135    let xp = player.and_then(|p| p.progression_xp.as_ref());
136    let sync = if xp.is_some() {
137        SheetSync::Full
138    } else {
139        SheetSync::MissingXpPools
140    };
141
142    let attributes = if let (Some(attrs), Some(xp)) = (attrs, xp) {
143        PRIMARY_STAT_ROWS
144            .iter()
145            .map(|(label, pool)| {
146                let pool_xp = pool(xp);
147                let level =
148                    PrimaryAttributes::display(flatland_protocol::primary_internal(&attrs, label));
149                let next_level = level.saturating_add(1);
150                let prog = curve.band_progress(pool_xp, level, next_level);
151                AttributeRow {
152                    label,
153                    level,
154                    next_level: prog.next,
155                    progress: prog.progress as f32,
156                    xp_into_band: prog.xp_into_level,
157                    xp_band_size: (prog.xp_ceiling - prog.xp_floor).max(0.0),
158                    xp_to_next: prog.xp_to_next,
159                    pool_xp,
160                    above_baseline: pool_xp - bootstrap_primary,
161                }
162            })
163            .collect()
164    } else if let Some(attrs) = attrs {
165        PRIMARY_STAT_ROWS
166            .iter()
167            .map(|(label, _)| AttributeRow {
168                label,
169                level: PrimaryAttributes::display(flatland_protocol::primary_internal(
170                    &attrs, label,
171                )),
172                next_level: 0,
173                progress: 0.0,
174                xp_into_band: 0.0,
175                xp_band_size: 0.0,
176                xp_to_next: 0.0,
177                pool_xp: 0.0,
178                above_baseline: 0.0,
179            })
180            .collect()
181    } else {
182        Vec::new()
183    };
184
185    let skills = if let (Some(skills), Some(xp)) = (skills, xp) {
186        let mut rows: Vec<SkillRow> = SKILL_ROWS
187            .iter()
188            .map(|(name, pool, tier_fn)| {
189                let pool_xp = pool(xp);
190                let tier = tier_fn(skills);
191                let next_tier = tier.saturating_add(1).min(10);
192                let current_display = if tier == 0 {
193                    0
194                } else {
195                    tier.saturating_mul(10)
196                };
197                let next_display = if next_tier >= 10 {
198                    100
199                } else {
200                    next_tier.saturating_mul(10)
201                };
202                let prog = curve.band_progress(pool_xp, current_display, next_display);
203                SkillRow {
204                    name,
205                    tier,
206                    next_tier,
207                    progress: prog.progress as f32,
208                    xp_into_band: prog.xp_into_level,
209                    xp_band_size: (prog.xp_ceiling - prog.xp_floor).max(0.0),
210                    xp_to_next: prog.xp_to_next,
211                    pool_xp,
212                }
213            })
214            .collect();
215        rows.sort_by(|a, b| {
216            b.pool_xp
217                .partial_cmp(&a.pool_xp)
218                .unwrap_or(std::cmp::Ordering::Equal)
219        });
220        rows
221    } else if let Some(skills) = skills {
222        SKILL_ROWS
223            .iter()
224            .map(|(name, _, tier_fn)| SkillRow {
225                name,
226                tier: tier_fn(skills),
227                next_tier: 0,
228                progress: 0.0,
229                xp_into_band: 0.0,
230                xp_band_size: 0.0,
231                xp_to_next: 0.0,
232                pool_xp: 0.0,
233            })
234            .collect()
235    } else {
236        Vec::new()
237    };
238
239    let ability_mastery = {
240        let mut rows: Vec<AbilityMasteryRow> = Vec::new();
241        if let Some(xp) = xp {
242            for id in &state.known_abilities {
243                let pool_xp = xp.ability.get(id).copied().unwrap_or(0.0);
244                let prog = curve.skill_progress(pool_xp);
245                let next_tier = prog.next.min(10);
246                rows.push(AbilityMasteryRow {
247                    label: flatland_protocol::humanize_snake_id(id),
248                    ability_id: id.clone(),
249                    tier: prog.current,
250                    next_tier,
251                    progress: prog.progress as f32,
252                    xp_into_band: prog.xp_into_level,
253                    xp_band_size: (prog.xp_ceiling - prog.xp_floor).max(0.0),
254                    xp_to_next: prog.xp_to_next,
255                    pool_xp,
256                });
257            }
258            rows.sort_by(|a, b| {
259                b.pool_xp
260                    .partial_cmp(&a.pool_xp)
261                    .unwrap_or(std::cmp::Ordering::Equal)
262            });
263        } else if !state.ability_mastery.is_empty() {
264            for row in state.ability_mastery.values() {
265                let prog = curve.skill_progress(row.xp);
266                rows.push(AbilityMasteryRow {
267                    label: flatland_protocol::humanize_snake_id(&row.ability_id),
268                    ability_id: row.ability_id.clone(),
269                    tier: row.tier,
270                    next_tier: prog.next.min(10),
271                    progress: prog.progress as f32,
272                    xp_into_band: prog.xp_into_level,
273                    xp_band_size: (prog.xp_ceiling - prog.xp_floor).max(0.0),
274                    xp_to_next: row.xp_to_next,
275                    pool_xp: row.xp,
276                });
277            }
278            rows.sort_by(|a, b| {
279                b.pool_xp
280                    .partial_cmp(&a.pool_xp)
281                    .unwrap_or(std::cmp::Ordering::Equal)
282            });
283        }
284        rows
285    };
286
287    let derived = attrs.map(|a| a.derived_preview());
288    let pools = state
289        .vitals()
290        .map(|v| {
291            vec![
292                PoolRow {
293                    label: "Health",
294                    current: v.health,
295                    max: v.health_max,
296                },
297                PoolRow {
298                    label: "Mana",
299                    current: v.mana,
300                    max: v.mana_max,
301                },
302                PoolRow {
303                    label: "Stamina",
304                    current: v.stamina,
305                    max: v.stamina_max,
306                },
307                PoolRow {
308                    label: "Hunger",
309                    current: v.hunger,
310                    max: v.hunger_max,
311                },
312                PoolRow {
313                    label: "Thirst",
314                    current: v.thirst,
315                    max: v.thirst_max,
316                },
317            ]
318        })
319        .unwrap_or_default();
320
321    let (px, py, pz) = state.player_position_with_z();
322    let mut footer_hints = training_hints(&attributes, sync, &curve);
323    if let Some(top) = most_trained_attribute(&attributes) {
324        if top.above_baseline > 0.0001 {
325            footer_hints.insert(
326                0,
327                format!(
328                    "Most trained primary: {} ({} above baseline)",
329                    top.label,
330                    format_xp_delta(top.above_baseline)
331                ),
332            );
333        }
334    }
335    if let Some(top) = skills.iter().find(|s| s.pool_xp > 0.0001) {
336        footer_hints.insert(
337            0,
338            format!(
339                "Most trained skill: {} ({} XP)",
340                top.name,
341                format_xp_value(top.pool_xp)
342            ),
343        );
344    }
345    if let Some(top) = ability_mastery.iter().find(|s| s.pool_xp > 0.0001) {
346        footer_hints.insert(
347            0,
348            format!(
349                "Most trained ability: {} (tier {} · {} XP)",
350                top.label,
351                top.tier,
352                format_xp_value(top.pool_xp)
353            ),
354        );
355    }
356
357    CharacterSheet {
358        sync,
359        name: player
360            .map(|p| p.label.clone())
361            .unwrap_or_else(|| "—".into()),
362        position: (px, py, pz),
363        inside_building: player.and_then(|p| p.inside_building.clone()),
364        life: state
365            .vitals()
366            .map(|v| match v.life_state {
367                LifeState::Alive => "alive".into(),
368                LifeState::Dead => "dead".into(),
369            })
370            .unwrap_or_else(|| "—".into()),
371        deaths: state.vitals().map(|v| v.deaths).unwrap_or(0),
372        money: state.currency_display(),
373        attributes,
374        skills,
375        ability_mastery,
376        derived_attack: derived.map(|d| d.attack_power).unwrap_or(0.0),
377        derived_spell: derived.map(|d| d.spell_power).unwrap_or(0.0),
378        derived_evasion: derived.map(|d| d.evasion).unwrap_or(0.0),
379        derived_carry_kg: derived
380            .map(|d| d.carry_mass_max)
381            .unwrap_or(state.carry_mass_max),
382        derived_sight_m: derived.map(|d| d.sight_range_m).unwrap_or(0.0),
383        derived_fov_deg: derived.map(|d| d.fov_deg).unwrap_or(0.0),
384        pools,
385        carry_mass: state.carry_mass,
386        carry_mass_max: state.carry_mass_max,
387        encumbrance: match state.encumbrance {
388            EncumbranceState::Light => "Light",
389            EncumbranceState::Heavy => "Heavy",
390            EncumbranceState::Orange => "Overloaded",
391            EncumbranceState::Over => "Over",
392        },
393        move_speed_mps: state.move_speed_mps,
394        move_speed_mult: state.move_speed_mult,
395        mainhand: state
396            .mainhand_label
397            .clone()
398            .or_else(|| state.mainhand_template_id.clone())
399            .unwrap_or_else(|| "(unarmed)".into()),
400        offhand: if state.mainhand_hand_slots >= 2 {
401            "(locked — 2H)".into()
402        } else {
403            state
404                .offhand_label
405                .clone()
406                .or_else(|| state.offhand_template_id.clone())
407                .unwrap_or_else(|| "(empty)".into())
408        },
409        defense_summary: state.defense.as_ref().map(|d| {
410            format!(
411                "armor {:.0} · VIT {:.0} · DR {:.0}% · press p for Equip",
412                d.armor_physical,
413                d.vitality_contribution,
414                d.estimated_physical_dr * 100.0
415            )
416        }),
417        worn: state
418            .worn
419            .iter()
420            .map(|(slot, stack)| {
421                let label = stack
422                    .display_name
423                    .clone()
424                    .unwrap_or_else(|| stack.template_id.clone());
425                let bindings = crate::format_status_bindings_suffix(
426                    &stack.status_bindings,
427                    state.tick,
428                    crate::DEFAULT_TICK_HZ,
429                );
430                (
431                    body_slot_label(*slot).to_string(),
432                    format!("{label}{bindings}"),
433                )
434            })
435            .collect(),
436        active_statuses: state
437            .statuses
438            .iter()
439            .map(|s| {
440                let ttl = s
441                    .remaining_sec
442                    .map(|sec| {
443                        if sec >= 120.0 {
444                            format!(" · {:.0}m", sec / 60.0)
445                        } else {
446                            format!(" · {sec:.0}s")
447                        }
448                    })
449                    .unwrap_or_default();
450                format!("{}{ttl}", s.label)
451            })
452            .collect(),
453        keychain_count: state.keychain_stacks.len(),
454        in_combat: state.in_combat,
455        auto_attack: state.auto_attack,
456        blocking: state.blocking_active,
457        target_slots: state.max_target_slots,
458        has_los: state.combat_has_los,
459        target_label: state
460            .combat_target_label
461            .clone()
462            .or_else(|| state.combat_target.map(|id| format!("entity {id}"))),
463        weapon_ability: (!state.weapon_ability_id.is_empty())
464            .then(|| flatland_protocol::humanize_snake_id(&state.weapon_ability_id)),
465        rotation_summary: (!state.combat_slots.is_empty()).then(|| {
466            state
467                .combat_slots
468                .iter()
469                .map(|s| {
470                    format!(
471                        "T{}={}",
472                        s.slot_index,
473                        s.preset_id.as_deref().unwrap_or("—")
474                    )
475                })
476                .collect::<Vec<_>>()
477                .join("  ")
478        }),
479        learned_abilities: {
480            let mut labels: Vec<String> = state
481                .known_abilities
482                .iter()
483                .map(|id| flatland_protocol::humanize_snake_id(id))
484                .collect();
485            if labels.is_empty() {
486                labels.push("Unarmed".into());
487            }
488            labels
489        },
490        blueprint_count: state.blueprints.len(),
491        footer_hints,
492    }
493}
494
495fn most_trained_attribute<'a>(rows: &'a [AttributeRow]) -> Option<&'a AttributeRow> {
496    rows.iter()
497        .max_by(|a, b| {
498            a.above_baseline
499                .partial_cmp(&b.above_baseline)
500                .unwrap_or(std::cmp::Ordering::Equal)
501        })
502        .filter(|r| r.above_baseline > 0.0)
503}
504
505fn training_hints(
506    attributes: &[AttributeRow],
507    sync: SheetSync,
508    curve: &ProgressionCurve,
509) -> Vec<String> {
510    let mut hints = Vec::new();
511    match sync {
512        SheetSync::MissingXpPools => {
513            hints.push("XP pools missing on wire — reconnect after updating server/client.".into());
514        }
515        SheetSync::Full => {
516            let band = curve.xp_for_display_level((curve.baseline_display + 1) as f64)
517                - curve.xp_for_display_level(curve.baseline_display as f64);
518            hints.push(format!(
519                "Progression is slow by design: ~{:.0} XP per +1 stat at level {} (~{:.0} harvests).",
520                band,
521                curve.baseline_display,
522                band / 0.08
523            ));
524            hints.push(
525                "New characters start with equal primaries. Harvest → STR/STA/Logging; craft → DEX/INT/Crafting; combat → STR/DEX/Swords.".into(),
526            );
527            let diverged = attributes
528                .iter()
529                .filter(|r| r.above_baseline.abs() > 0.0001)
530                .count();
531            if diverged == 0 {
532                hints.push(
533                    "No stat has moved off baseline yet — keep harvesting, crafting, or fighting to diverge pools.".into(),
534                );
535            }
536        }
537    }
538    hints
539}
540
541pub fn format_xp_band_compact(into: f64, band: f64) -> String {
542    if band <= 0.0 {
543        return "—".into();
544    }
545    format!("{}/{}", format_xp_value(into), format_xp_value(band))
546}
547
548pub fn format_xp_band(into: f64, band: f64, to_next: f64, next: u16) -> String {
549    if band <= 0.0 {
550        return "—".into();
551    }
552    format!(
553        "{}/{} XP · {} to {next}",
554        format_xp_value(into),
555        format_xp_value(band),
556        format_xp_value(to_next),
557    )
558}
559
560/// Adaptive precision so tiny harvest gains (0.08 XP) don't display as 0.0.
561pub fn format_xp_value(v: f64) -> String {
562    let abs = v.abs();
563    if abs == 0.0 {
564        "0".into()
565    } else if abs >= 100.0 {
566        format!("{:.1}", v)
567    } else if abs >= 1.0 {
568        format!("{:.2}", v)
569    } else if abs >= 0.01 {
570        format!("{:.3}", v)
571    } else {
572        format!("{:.4}", v)
573    }
574}
575
576/// Signed delta from baseline — always enough decimals to see harvest ticks.
577pub fn format_xp_delta(v: f64) -> String {
578    let abs = v.abs();
579    if abs == 0.0 {
580        "0".into()
581    } else if abs >= 1.0 {
582        format!("{:+.2}", v)
583    } else if abs >= 0.01 {
584        format!("{:+.3}", v)
585    } else {
586        format!("{:+.4}", v)
587    }
588}
589
590pub fn format_progress_pct(progress: f32) -> String {
591    let pct = (progress * 100.0) as f64;
592    if pct >= 10.0 {
593        format!("{pct:.0}%")
594    } else if pct >= 0.01 {
595        format!("{pct:.2}%")
596    } else if pct > 0.0 {
597        format!("{pct:.3}%")
598    } else {
599        "0%".into()
600    }
601}
602
603pub fn format_pool_xp(pool: f64, above_baseline: f64) -> String {
604    if above_baseline.abs() > 0.0001 {
605        format!(
606            "{} ({})",
607            format_xp_value(pool),
608            format_xp_delta(above_baseline)
609        )
610    } else {
611        format_xp_value(pool)
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use flatland_protocol::{PlayerSkills, PrimaryAttributes, ProgressionXp};
619
620    #[test]
621    fn tiny_xp_values_are_not_rounded_to_zero() {
622        assert_eq!(format_xp_value(0.08), "0.080");
623        assert_eq!(format_xp_delta(0.08), "+0.080");
624        assert_eq!(
625            format_xp_band(0.08, 58.65, 58.57, 16),
626            "0.080/58.65 XP · 58.57 to 16"
627        );
628        assert_eq!(format_xp_band_compact(0.08, 58.65), "0.080/58.65");
629        assert_eq!(format_progress_pct(0.0014), "0.14%");
630    }
631
632    #[test]
633    fn bootstrap_band_is_zero_until_first_gain() {
634        let curve = ProgressionCurve::default();
635        let xp = ProgressionXp::bootstrap_new(15, curve.xp_base, curve.xp_growth);
636        let attrs = PrimaryAttributes::default();
637        let sheet = build_character_sheet(&minimal_state(attrs, xp.clone())).attributes;
638        let str_row = sheet.iter().find(|r| r.label == "STR").unwrap();
639        assert!(str_row.xp_into_band.abs() < 0.001);
640
641        let mut gained = xp;
642        gained.strength += 0.08;
643        let sheet2 = build_character_sheet(&minimal_state(attrs, gained)).attributes;
644        let str2 = sheet2.iter().find(|r| r.label == "STR").unwrap();
645        assert!((str2.xp_into_band - 0.08).abs() < 0.001);
646        assert!(str2.progress > 0.0);
647    }
648
649    #[test]
650    fn learned_abilities_use_friendly_labels() {
651        let curve = ProgressionCurve::default();
652        let xp = ProgressionXp::bootstrap_new(15, curve.xp_base, curve.xp_growth);
653        let attrs = PrimaryAttributes::default();
654        let mut state = minimal_state(attrs, xp);
655        state.known_abilities = vec!["unarmed".into(), "heal_touch".into(), "fireball".into()];
656        let sheet = build_character_sheet(&state);
657        assert_eq!(
658            sheet.learned_abilities,
659            vec![
660                "Unarmed".to_string(),
661                "Heal Touch".into(),
662                "Fireball".into()
663            ]
664        );
665    }
666
667    #[test]
668    fn harvest_diverges_strength_from_intelligence() {
669        let curve = ProgressionCurve::default();
670        let mut xp = ProgressionXp::bootstrap_new(15, curve.xp_base, curve.xp_growth);
671        xp.strength += 1.0;
672        let attrs = PrimaryAttributes::default();
673        let rows = build_character_sheet(&minimal_state(attrs, xp)).attributes;
674        let str_row = rows.iter().find(|r| r.label == "STR").unwrap();
675        let int_row = rows.iter().find(|r| r.label == "INT").unwrap();
676        assert!(str_row.above_baseline > int_row.above_baseline);
677        assert!((str_row.pool_xp - int_row.pool_xp - 1.0).abs() < 0.01);
678    }
679
680    #[test]
681    fn move_speed_display_includes_load_percent_when_slow() {
682        let curve = ProgressionCurve::default();
683        let xp = ProgressionXp::bootstrap_new(15, curve.xp_base, curve.xp_growth);
684        let mut state = minimal_state(PrimaryAttributes::default(), xp);
685        state.move_speed_mps = 3.2;
686        state.move_speed_mult = 0.70;
687        let sheet = build_character_sheet(&state);
688        assert_eq!(sheet.move_speed_display(), "3.2 m/s (70% walk)");
689    }
690
691    fn minimal_state(attrs: PrimaryAttributes, xp: ProgressionXp) -> GameState {
692        use flatland_protocol::{EntityState, Transform, Velocity2D, WorldCoord};
693        let mut state = GameState {
694            session_id: 1,
695            entity_id: 1,
696            character_id: None,
697            tick: 0,
698            chunk_rev: 0,
699            content_rev: 0,
700            publish_rev: 0,
701            entities: Vec::new(),
702            player: None,
703            resource_nodes: Vec::new(),
704            ground_drops: Vec::new(),
705            placed_containers: Vec::new(),
706            buildings: Vec::new(),
707            doors: Vec::new(),
708            interior_map: None,
709            npcs: Vec::new(),
710            blueprints: Vec::new(),
711            building_materials: Vec::new(),
712            world_x0: 0.0,
713            world_y0: 0.0,
714            world_width_m: 256.0,
715            world_height_m: 256.0,
716            terrain_zones: Vec::new(),
717            z_platforms: Vec::new(),
718            z_transitions: Vec::new(),
719            z_bands_outdoor_backup: None,
720            world_clock: flatland_protocol::WorldClock::default(),
721            inventory: std::collections::HashMap::new(),
722            inventory_hints: std::collections::HashMap::new(),
723            item_catalog: std::collections::HashMap::new(),
724            logs: std::collections::VecDeque::new(),
725            intents_sent: 0,
726            ticks_received: 0,
727            connected: true,
728            disconnect_reason: None,
729            show_stats: false,
730            hud_log_hidden: false,
731            show_equip_menu: false,
732            equip_menu_index: 0,
733            show_craft_menu: false,
734            show_plot_build_menu: false,
735            plot_build_focus_wall: true,
736            plot_build_wall_index: 0,
737            plot_build_roof_index: 0,
738            craft_menu_index: 0,
739            craft_batch_quantity: 1,
740            craft_tab: crate::CraftTab::Ready,
741            craft_filter: String::new(),
742            craft_filter_focused: false,
743            craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
744            show_shop_menu: false,
745            shop_catalog: None,
746            bank_panel: None,
747            bank_menu_index: 0,
748            bank_ui_mode: crate::BankUiMode::Menu,
749            storage_panel: None,
750            market_panel: None,
751            market_menu_index: 0,
752            market_filter: String::new(),
753            market_filter_focused: false,
754            market_category_filter: None,
755            market_buy_confirm: None,
756            market_ui_mode: crate::MarketUiMode::Browse,
757            storage_menu_index: 0,
758            storage_ui_mode: crate::StorageUiMode::Menu,
759            shop_tab: crate::ShopTab::Buy,
760            shop_menu_index: 0,
761            shop_quantity: 1,
762            shop_trade_log: std::collections::VecDeque::new(),
763            show_npc_verb_menu: false,
764            npc_verb_target: None,
765            npc_verb_index: 0,
766            npc_verb_notice: None,
767            player_verbs: Default::default(),
768            social_chat: Default::default(),
769            trade_ui: Default::default(),
770            whisper_pouch_ui: Default::default(),
771            show_npc_chat: false,
772            npc_chat: None,
773            show_inventory_menu: false,
774            inventory_menu_index: 0,
775            inventory_tab: crate::InventoryTab::OnPerson,
776            inventory_filter: String::new(),
777            inventory_filter_focused: false,
778            show_move_picker: false,
779            move_picker_index: 0,
780            move_picker: None,
781            show_grant_picker: false,
782            grant_picker_index: 0,
783            grant_picker: None,
784            show_destroy_picker: false,
785            destroy_confirm_pending: false,
786            destroy_picker: None,
787            show_rename_prompt: false,
788            rename_plot_id: None,
789            highlighted_plot_id: None,
790            show_worker_rename: false,
791            rename_buffer: String::new(),
792            combat_target: None,
793            combat_target_label: None,
794            ground_target: None,
795            combat_fx: Vec::new(),
796            ground_hazards: Vec::new(),
797            property_zones: Vec::new(),
798            tax_zones: Vec::new(),
799            growth_zones: Vec::new(),
800            biome_zones: Vec::new(),
801            terrain_kind_nav: Vec::new(),
802            property_plots: Vec::new(),
803            property_plot_settings: None,
804            claim_mode: None,
805            relocate_mode: None,
806            sell_plot_confirm: None,
807            sell_plot_armed_at: None,
808            show_plant_menu: false,
809            plant_menu_index: 0,
810            show_farm_access: false,
811            farm_access_name_draft: String::new(),
812            farm_access_discount_bps: 0,
813            farm_access_index: 0,
814            plant_quantity: 1,
815            in_combat: false,
816            auto_attack: true,
817            combat_has_los: false,
818            attack_cd_ticks: 0,
819            gcd_ticks: 0,
820            weapon_ability_id: String::new(),
821            mainhand_template_id: None,
822            mainhand_label: None,
823            mainhand_instance_id: None,
824            offhand_template_id: None,
825            offhand_label: None,
826            offhand_instance_id: None,
827            mainhand_hand_slots: 1,
828            defense: None,
829            worn: std::collections::BTreeMap::new(),
830            carry_mass: 0.0,
831            carry_mass_max: 37.5,
832            encumbrance: flatland_protocol::EncumbranceState::Light,
833            move_speed_mps: 4.5,
834            move_speed_mult: 1.0,
835            inventory_stacks: Vec::new(),
836            keychain_stacks: Vec::new(),
837            whisper_pouch_stacks: Vec::new(),
838            combat_target_detail: None,
839            statuses: Vec::new(),
840            cast_progress: None,
841            timed_channel: None,
842            plot_build_offer: None,
843            ability_cooldowns: Vec::new(),
844            blocking_active: false,
845            max_target_slots: 1,
846            combat_slots: Vec::new(),
847            rotation_presets: Vec::new(),
848            known_abilities: Vec::new(),
849            ability_meta: std::collections::HashMap::new(),
850            ability_mastery: std::collections::HashMap::new(),
851            hotbar: vec![None; 9],
852            max_abilities_per_rotation: 0,
853            show_loadout_menu: false,
854            show_keychain_menu: false,
855            keychain_menu_index: 0,
856            show_rotation_editor: false,
857            rotation_editor: crate::RotationEditorState::default(),
858            show_quest_menu: false,
859            quest_menu_index: 0,
860            quest_withdraw_confirm: false,
861            hired_workers: Vec::new(),
862            show_workers_menu: false,
863            workers_menu_index: 0,
864            worker_dismiss_confirmation: None,
865            workers_menu_compact: false,
866            worker_step_display: std::collections::BTreeMap::new(),
867            worker_error_display: std::collections::BTreeMap::new(),
868            worker_health_ring_until: std::collections::BTreeMap::new(),
869            pending_worker_hire_since: None,
870            show_worker_give_picker: false,
871            worker_give_picker_index: 0,
872            worker_give_picker: None,
873            show_worker_give_target_picker: false,
874            worker_give_target_picker_index: 0,
875            worker_give_target_picker: None,
876            show_worker_take_picker: false,
877            worker_take_picker_index: 0,
878            worker_take_picker: None,
879            show_worker_teach_picker: false,
880            worker_teach_picker_index: 0,
881            worker_teach_picker: None,
882            worker_route_editor: None,
883            progression_curve: None,
884            show_quest_offer: false,
885            pending_quest_offers: Vec::new(),
886            quest_offer_index: 0,
887            interactables: Vec::new(),
888            ledger: None,
889            career: None,
890            character_sheet_tab: crate::CharacterSheetTab::Character,
891            ledger_period: crate::LedgerPeriod::Day,
892            quest_log: Vec::new(),
893            harvest_in_progress: false,
894            harvest_started_at: None,
895            pending_craft_ack: None,
896            craft_channel_blueprint_id: None,
897            pending_worker_job_ack: None,
898            attending_worker_instance_id: None,
899            loadout_menu_index: 0,
900            loadout_hotbar_slot: 1,
901            loadout_ability_index: 0,
902            loadout_focus_presets: false,
903        };
904        state.player = Some(EntityState {
905            id: 1,
906            transform: Transform {
907                position: WorldCoord::surface(0.0, 0.0),
908                yaw: 0.0,
909                velocity: Velocity2D { vx: 0.0, vy: 0.0 },
910            },
911            label: "Hero".into(),
912            vitals: Some(flatland_protocol::PlayerVitals::from_attributes(attrs)),
913            attributes: Some(attrs),
914            skills: Some(PlayerSkills::default()),
915            inside_building: None,
916            tile_id: None,
917            paperdoll_ref: None,
918            draw_scale: 1.0,
919            presentation_state: None,
920            sprite_mode: None,
921            progression_xp: Some(xp),
922            combat_cues: vec![],
923            statuses: vec![],
924        });
925        state
926    }
927}