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            entities: Vec::new(),
504            player: None,
505            resource_nodes: Vec::new(),
506            ground_drops: Vec::new(),
507            placed_containers: Vec::new(),
508            buildings: Vec::new(),
509            doors: Vec::new(),
510            interior_map: None,
511            npcs: Vec::new(),
512            blueprints: Vec::new(),
513            world_width_m: 256.0,
514            world_height_m: 256.0,
515            terrain_zones: Vec::new(),
516            z_platforms: Vec::new(),
517            z_transitions: Vec::new(),
518            world_clock: flatland_protocol::WorldClock::default(),
519            inventory: std::collections::HashMap::new(),
520            inventory_hints: std::collections::HashMap::new(),
521            logs: std::collections::VecDeque::new(),
522            intents_sent: 0,
523            ticks_received: 0,
524            connected: true,
525            disconnect_reason: None,
526            show_stats: false,
527            show_craft_menu: false,
528            craft_menu_index: 0,
529            craft_batch_quantity: 1,
530            show_shop_menu: false,
531            shop_catalog: None,
532            shop_tab: crate::ShopTab::Buy,
533            shop_menu_index: 0,
534            shop_quantity: 1,
535            shop_trade_log: std::collections::VecDeque::new(),
536            show_npc_verb_menu: false,
537            npc_verb_target: None,
538            npc_verb_index: 0,
539            show_npc_chat: false,
540            npc_chat: None,
541            show_inventory_menu: false,
542            inventory_menu_index: 0,
543            show_move_picker: false,
544            move_picker_index: 0,
545            move_picker: None,
546            show_destroy_picker: false,
547            destroy_confirm_pending: false,
548            destroy_picker: None,
549            show_rename_prompt: false,
550            rename_buffer: String::new(),
551            combat_target: None,
552            combat_target_label: None,
553            in_combat: false,
554            auto_attack: true,
555            combat_has_los: false,
556            attack_cd_ticks: 0,
557            gcd_ticks: 0,
558            weapon_ability_id: String::new(),
559            mainhand_template_id: None,
560            mainhand_label: None,
561            worn: std::collections::BTreeMap::new(),
562            carry_mass: 0.0,
563            carry_mass_max: 37.5,
564            encumbrance: flatland_protocol::EncumbranceState::Light,
565            inventory_stacks: Vec::new(),
566            keychain_stacks: Vec::new(),
567            combat_target_detail: None,
568            cast_progress: None,
569            ability_cooldowns: Vec::new(),
570            blocking_active: false,
571            max_target_slots: 1,
572            combat_slots: Vec::new(),
573            rotation_presets: Vec::new(),
574            show_loadout_menu: false,
575            show_keychain_menu: false,
576            keychain_menu_index: 0,
577            show_rotation_editor: false,
578            rotation_editor: crate::RotationEditorState::default(),
579            show_quest_menu: false,
580            quest_menu_index: 0,
581            quest_withdraw_confirm: false,
582            progression_curve: None,
583            show_quest_offer: false,
584            pending_quest_offer: None,
585            interactables: Vec::new(),
586            quest_log: Vec::new(),
587            harvest_in_progress: false,
588            harvest_started_at: None,
589            pending_craft_ack: None,
590            loadout_menu_index: 0,
591        };
592        state.player = Some(EntityState {
593            id: 1,
594            transform: Transform {
595                position: WorldCoord::surface(0.0, 0.0),
596                yaw: 0.0,
597                velocity: Velocity2D { vx: 0.0, vy: 0.0 },
598            },
599            label: "Hero".into(),
600            vitals: Some(flatland_protocol::PlayerVitals::from_attributes(attrs)),
601            attributes: Some(attrs),
602            skills: Some(PlayerSkills::default()),
603            inside_building: None,
604            tile_id: None,
605            presentation_state: None,
606            sprite_mode: None,
607            progression_xp: Some(xp),
608        });
609        state
610    }
611}