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