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