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 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
414pub 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
430pub 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}