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