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