1use 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 Full,
14 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 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
438pub 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
454pub 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}