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