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 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
395pub 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
411pub 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}