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