1use flatland_protocol::{
4 EncumbranceState, LifeState, PrimaryAttributes, ProgressionCurve, PRIMARY_STAT_ROWS, SKILL_ROWS,
5};
6
7use crate::{body_slot_label, GameState};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum SheetSync {
11 Full,
13 MissingXpPools,
15}
16
17#[derive(Debug, Clone)]
18pub struct CharacterSheet {
19 pub sync: SheetSync,
20 pub name: String,
21 pub position: (f32, f32, f32),
22 pub inside_building: Option<String>,
23 pub life: String,
24 pub deaths: u32,
25 pub money: String,
26 pub attributes: Vec<AttributeRow>,
27 pub skills: Vec<SkillRow>,
28 pub ability_mastery: Vec<AbilityMasteryRow>,
30 pub derived_attack: f32,
31 pub derived_spell: f32,
32 pub derived_evasion: f32,
33 pub derived_carry_kg: f32,
34 pub derived_sight_m: f32,
35 pub derived_fov_deg: f32,
36 pub pools: Vec<PoolRow>,
37 pub carry_mass: f32,
38 pub carry_mass_max: f32,
39 pub encumbrance: &'static str,
40 pub move_speed_mps: f32,
42 pub move_speed_mult: f32,
44 pub mainhand: String,
45 pub offhand: String,
46 pub defense_summary: Option<String>,
47 pub worn: Vec<(String, String)>,
48 pub active_statuses: Vec<String>,
50 pub keychain_count: usize,
51 pub in_combat: bool,
52 pub auto_attack: bool,
53 pub blocking: bool,
54 pub target_slots: u8,
55 pub has_los: bool,
56 pub target_label: Option<String>,
57 pub weapon_ability: Option<String>,
58 pub rotation_summary: Option<String>,
59 pub learned_abilities: Vec<String>,
61 pub blueprint_count: usize,
62 pub footer_hints: Vec<String>,
63}
64
65impl CharacterSheet {
66 pub fn move_speed_display(&self) -> String {
68 if self.move_speed_mps <= 0.05 {
69 return "—".into();
70 }
71 let load = if self.move_speed_mult > 0.05 {
72 self.move_speed_mult
73 } else {
74 1.0
75 };
76 if (load - 1.0).abs() > 0.02 {
77 format!("{:.1} m/s ({:.0}% walk)", self.move_speed_mps, load * 100.0)
78 } else {
79 format!("{:.1} m/s", self.move_speed_mps)
80 }
81 }
82}
83
84#[derive(Debug, Clone)]
85pub struct AttributeRow {
86 pub label: &'static str,
87 pub level: u16,
88 pub next_level: u16,
89 pub progress: f32,
90 pub xp_into_band: f64,
91 pub xp_band_size: f64,
92 pub xp_to_next: f64,
93 pub pool_xp: f64,
94 pub above_baseline: f64,
95}
96
97#[derive(Debug, Clone)]
98pub struct SkillRow {
99 pub name: &'static str,
100 pub tier: u16,
101 pub next_tier: u16,
102 pub progress: f32,
103 pub xp_into_band: f64,
104 pub xp_band_size: f64,
105 pub xp_to_next: f64,
106 pub pool_xp: f64,
107}
108
109#[derive(Debug, Clone)]
110pub struct AbilityMasteryRow {
111 pub label: String,
112 pub ability_id: String,
113 pub tier: u16,
114 pub next_tier: u16,
115 pub progress: f32,
116 pub xp_into_band: f64,
117 pub xp_band_size: f64,
118 pub xp_to_next: f64,
119 pub pool_xp: f64,
120}
121
122#[derive(Debug, Clone)]
123pub struct PoolRow {
124 pub label: &'static str,
125 pub current: f32,
126 pub max: f32,
127}
128
129pub fn build_character_sheet(state: &GameState) -> CharacterSheet {
130 let curve = state.progression_curve.unwrap_or_default();
131 let bootstrap_primary = curve.xp_for_display_level(curve.baseline_display as f64);
132 let player = state.player.as_ref();
133 let attrs = player.and_then(|p| p.attributes);
134 let skills = player.and_then(|p| p.skills.as_ref());
135 let xp = player.and_then(|p| p.progression_xp.as_ref());
136 let sync = if xp.is_some() {
137 SheetSync::Full
138 } else {
139 SheetSync::MissingXpPools
140 };
141
142 let attributes = if let (Some(attrs), Some(xp)) = (attrs, xp) {
143 PRIMARY_STAT_ROWS
144 .iter()
145 .map(|(label, pool)| {
146 let pool_xp = pool(xp);
147 let level =
148 PrimaryAttributes::display(flatland_protocol::primary_internal(&attrs, label));
149 let next_level = level.saturating_add(1);
150 let prog = curve.band_progress(pool_xp, level, next_level);
151 AttributeRow {
152 label,
153 level,
154 next_level: prog.next,
155 progress: prog.progress as f32,
156 xp_into_band: prog.xp_into_level,
157 xp_band_size: (prog.xp_ceiling - prog.xp_floor).max(0.0),
158 xp_to_next: prog.xp_to_next,
159 pool_xp,
160 above_baseline: pool_xp - bootstrap_primary,
161 }
162 })
163 .collect()
164 } else if let Some(attrs) = attrs {
165 PRIMARY_STAT_ROWS
166 .iter()
167 .map(|(label, _)| AttributeRow {
168 label,
169 level: PrimaryAttributes::display(flatland_protocol::primary_internal(
170 &attrs, label,
171 )),
172 next_level: 0,
173 progress: 0.0,
174 xp_into_band: 0.0,
175 xp_band_size: 0.0,
176 xp_to_next: 0.0,
177 pool_xp: 0.0,
178 above_baseline: 0.0,
179 })
180 .collect()
181 } else {
182 Vec::new()
183 };
184
185 let skills = if let (Some(skills), Some(xp)) = (skills, xp) {
186 let mut rows: Vec<SkillRow> = SKILL_ROWS
187 .iter()
188 .map(|(name, pool, tier_fn)| {
189 let pool_xp = pool(xp);
190 let tier = tier_fn(skills);
191 let next_tier = tier.saturating_add(1).min(10);
192 let current_display = if tier == 0 {
193 0
194 } else {
195 tier.saturating_mul(10)
196 };
197 let next_display = if next_tier >= 10 {
198 100
199 } else {
200 next_tier.saturating_mul(10)
201 };
202 let prog = curve.band_progress(pool_xp, current_display, next_display);
203 SkillRow {
204 name,
205 tier,
206 next_tier,
207 progress: prog.progress as f32,
208 xp_into_band: prog.xp_into_level,
209 xp_band_size: (prog.xp_ceiling - prog.xp_floor).max(0.0),
210 xp_to_next: prog.xp_to_next,
211 pool_xp,
212 }
213 })
214 .collect();
215 rows.sort_by(|a, b| {
216 b.pool_xp
217 .partial_cmp(&a.pool_xp)
218 .unwrap_or(std::cmp::Ordering::Equal)
219 });
220 rows
221 } else if let Some(skills) = skills {
222 SKILL_ROWS
223 .iter()
224 .map(|(name, _, tier_fn)| SkillRow {
225 name,
226 tier: tier_fn(skills),
227 next_tier: 0,
228 progress: 0.0,
229 xp_into_band: 0.0,
230 xp_band_size: 0.0,
231 xp_to_next: 0.0,
232 pool_xp: 0.0,
233 })
234 .collect()
235 } else {
236 Vec::new()
237 };
238
239 let ability_mastery = {
240 let mut rows: Vec<AbilityMasteryRow> = Vec::new();
241 if let Some(xp) = xp {
242 for id in &state.known_abilities {
243 let pool_xp = xp.ability.get(id).copied().unwrap_or(0.0);
244 let prog = curve.skill_progress(pool_xp);
245 let next_tier = prog.next.min(10);
246 rows.push(AbilityMasteryRow {
247 label: flatland_protocol::humanize_snake_id(id),
248 ability_id: id.clone(),
249 tier: prog.current,
250 next_tier,
251 progress: prog.progress as f32,
252 xp_into_band: prog.xp_into_level,
253 xp_band_size: (prog.xp_ceiling - prog.xp_floor).max(0.0),
254 xp_to_next: prog.xp_to_next,
255 pool_xp,
256 });
257 }
258 rows.sort_by(|a, b| {
259 b.pool_xp
260 .partial_cmp(&a.pool_xp)
261 .unwrap_or(std::cmp::Ordering::Equal)
262 });
263 } else if !state.ability_mastery.is_empty() {
264 for row in state.ability_mastery.values() {
265 let prog = curve.skill_progress(row.xp);
266 rows.push(AbilityMasteryRow {
267 label: flatland_protocol::humanize_snake_id(&row.ability_id),
268 ability_id: row.ability_id.clone(),
269 tier: row.tier,
270 next_tier: prog.next.min(10),
271 progress: prog.progress as f32,
272 xp_into_band: prog.xp_into_level,
273 xp_band_size: (prog.xp_ceiling - prog.xp_floor).max(0.0),
274 xp_to_next: row.xp_to_next,
275 pool_xp: row.xp,
276 });
277 }
278 rows.sort_by(|a, b| {
279 b.pool_xp
280 .partial_cmp(&a.pool_xp)
281 .unwrap_or(std::cmp::Ordering::Equal)
282 });
283 }
284 rows
285 };
286
287 let derived = attrs.map(|a| a.derived_preview());
288 let pools = state
289 .vitals()
290 .map(|v| {
291 vec![
292 PoolRow {
293 label: "Health",
294 current: v.health,
295 max: v.health_max,
296 },
297 PoolRow {
298 label: "Mana",
299 current: v.mana,
300 max: v.mana_max,
301 },
302 PoolRow {
303 label: "Stamina",
304 current: v.stamina,
305 max: v.stamina_max,
306 },
307 PoolRow {
308 label: "Hunger",
309 current: v.hunger,
310 max: v.hunger_max,
311 },
312 PoolRow {
313 label: "Thirst",
314 current: v.thirst,
315 max: v.thirst_max,
316 },
317 ]
318 })
319 .unwrap_or_default();
320
321 let (px, py, pz) = state.player_position_with_z();
322 let mut footer_hints = training_hints(&attributes, sync, &curve);
323 if let Some(top) = most_trained_attribute(&attributes) {
324 if top.above_baseline > 0.0001 {
325 footer_hints.insert(
326 0,
327 format!(
328 "Most trained primary: {} ({} above baseline)",
329 top.label,
330 format_xp_delta(top.above_baseline)
331 ),
332 );
333 }
334 }
335 if let Some(top) = skills.iter().find(|s| s.pool_xp > 0.0001) {
336 footer_hints.insert(
337 0,
338 format!(
339 "Most trained skill: {} ({} XP)",
340 top.name,
341 format_xp_value(top.pool_xp)
342 ),
343 );
344 }
345 if let Some(top) = ability_mastery.iter().find(|s| s.pool_xp > 0.0001) {
346 footer_hints.insert(
347 0,
348 format!(
349 "Most trained ability: {} (tier {} · {} XP)",
350 top.label,
351 top.tier,
352 format_xp_value(top.pool_xp)
353 ),
354 );
355 }
356
357 CharacterSheet {
358 sync,
359 name: player
360 .map(|p| p.label.clone())
361 .unwrap_or_else(|| "—".into()),
362 position: (px, py, pz),
363 inside_building: player.and_then(|p| p.inside_building.clone()),
364 life: state
365 .vitals()
366 .map(|v| match v.life_state {
367 LifeState::Alive => "alive".into(),
368 LifeState::Dead => "dead".into(),
369 })
370 .unwrap_or_else(|| "—".into()),
371 deaths: state.vitals().map(|v| v.deaths).unwrap_or(0),
372 money: state.currency_display(),
373 attributes,
374 skills,
375 ability_mastery,
376 derived_attack: derived.map(|d| d.attack_power).unwrap_or(0.0),
377 derived_spell: derived.map(|d| d.spell_power).unwrap_or(0.0),
378 derived_evasion: derived.map(|d| d.evasion).unwrap_or(0.0),
379 derived_carry_kg: derived
380 .map(|d| d.carry_mass_max)
381 .unwrap_or(state.carry_mass_max),
382 derived_sight_m: derived.map(|d| d.sight_range_m).unwrap_or(0.0),
383 derived_fov_deg: derived.map(|d| d.fov_deg).unwrap_or(0.0),
384 pools,
385 carry_mass: state.carry_mass,
386 carry_mass_max: state.carry_mass_max,
387 encumbrance: match state.encumbrance {
388 EncumbranceState::Light => "Light",
389 EncumbranceState::Heavy => "Heavy",
390 EncumbranceState::Orange => "Overloaded",
391 EncumbranceState::Over => "Over",
392 },
393 move_speed_mps: state.move_speed_mps,
394 move_speed_mult: state.move_speed_mult,
395 mainhand: state
396 .mainhand_label
397 .clone()
398 .or_else(|| state.mainhand_template_id.clone())
399 .unwrap_or_else(|| "(unarmed)".into()),
400 offhand: if state.mainhand_hand_slots >= 2 {
401 "(locked — 2H)".into()
402 } else {
403 state
404 .offhand_label
405 .clone()
406 .or_else(|| state.offhand_template_id.clone())
407 .unwrap_or_else(|| "(empty)".into())
408 },
409 defense_summary: state.defense.as_ref().map(|d| {
410 format!(
411 "armor {:.0} · VIT {:.0} · DR {:.0}% · press p for Equip",
412 d.armor_physical,
413 d.vitality_contribution,
414 d.estimated_physical_dr * 100.0
415 )
416 }),
417 worn: state
418 .worn
419 .iter()
420 .map(|(slot, stack)| {
421 let label = stack
422 .display_name
423 .clone()
424 .unwrap_or_else(|| stack.template_id.clone());
425 let bindings = crate::format_status_bindings_suffix(
426 &stack.status_bindings,
427 state.tick,
428 crate::DEFAULT_TICK_HZ,
429 );
430 (
431 body_slot_label(*slot).to_string(),
432 format!("{label}{bindings}"),
433 )
434 })
435 .collect(),
436 active_statuses: state
437 .statuses
438 .iter()
439 .map(|s| {
440 let ttl = s
441 .remaining_sec
442 .map(|sec| {
443 if sec >= 120.0 {
444 format!(" · {:.0}m", sec / 60.0)
445 } else {
446 format!(" · {sec:.0}s")
447 }
448 })
449 .unwrap_or_default();
450 format!("{}{ttl}", s.label)
451 })
452 .collect(),
453 keychain_count: state.keychain_stacks.len(),
454 in_combat: state.in_combat,
455 auto_attack: state.auto_attack,
456 blocking: state.blocking_active,
457 target_slots: state.max_target_slots,
458 has_los: state.combat_has_los,
459 target_label: state
460 .combat_target_label
461 .clone()
462 .or_else(|| state.combat_target.map(|id| format!("entity {id}"))),
463 weapon_ability: (!state.weapon_ability_id.is_empty())
464 .then(|| flatland_protocol::humanize_snake_id(&state.weapon_ability_id)),
465 rotation_summary: (!state.combat_slots.is_empty()).then(|| {
466 state
467 .combat_slots
468 .iter()
469 .map(|s| {
470 format!(
471 "T{}={}",
472 s.slot_index,
473 s.preset_id.as_deref().unwrap_or("—")
474 )
475 })
476 .collect::<Vec<_>>()
477 .join(" ")
478 }),
479 learned_abilities: {
480 let mut labels: Vec<String> = state
481 .known_abilities
482 .iter()
483 .map(|id| flatland_protocol::humanize_snake_id(id))
484 .collect();
485 if labels.is_empty() {
486 labels.push("Unarmed".into());
487 }
488 labels
489 },
490 blueprint_count: state.blueprints.len(),
491 footer_hints,
492 }
493}
494
495fn most_trained_attribute<'a>(rows: &'a [AttributeRow]) -> Option<&'a AttributeRow> {
496 rows.iter()
497 .max_by(|a, b| {
498 a.above_baseline
499 .partial_cmp(&b.above_baseline)
500 .unwrap_or(std::cmp::Ordering::Equal)
501 })
502 .filter(|r| r.above_baseline > 0.0)
503}
504
505fn training_hints(
506 attributes: &[AttributeRow],
507 sync: SheetSync,
508 curve: &ProgressionCurve,
509) -> Vec<String> {
510 let mut hints = Vec::new();
511 match sync {
512 SheetSync::MissingXpPools => {
513 hints.push("XP pools missing on wire — reconnect after updating server/client.".into());
514 }
515 SheetSync::Full => {
516 let band = curve.xp_for_display_level((curve.baseline_display + 1) as f64)
517 - curve.xp_for_display_level(curve.baseline_display as f64);
518 hints.push(format!(
519 "Progression is slow by design: ~{:.0} XP per +1 stat at level {} (~{:.0} harvests).",
520 band,
521 curve.baseline_display,
522 band / 0.08
523 ));
524 hints.push(
525 "New characters start with equal primaries. Harvest → STR/STA/Logging; craft → DEX/INT/Crafting; combat → STR/DEX/Swords.".into(),
526 );
527 let diverged = attributes
528 .iter()
529 .filter(|r| r.above_baseline.abs() > 0.0001)
530 .count();
531 if diverged == 0 {
532 hints.push(
533 "No stat has moved off baseline yet — keep harvesting, crafting, or fighting to diverge pools.".into(),
534 );
535 }
536 }
537 }
538 hints
539}
540
541pub fn format_xp_band_compact(into: f64, band: f64) -> String {
542 if band <= 0.0 {
543 return "—".into();
544 }
545 format!("{}/{}", format_xp_value(into), format_xp_value(band))
546}
547
548pub fn format_xp_band(into: f64, band: f64, to_next: f64, next: u16) -> String {
549 if band <= 0.0 {
550 return "—".into();
551 }
552 format!(
553 "{}/{} XP · {} to {next}",
554 format_xp_value(into),
555 format_xp_value(band),
556 format_xp_value(to_next),
557 )
558}
559
560pub fn format_xp_value(v: f64) -> String {
562 let abs = v.abs();
563 if abs == 0.0 {
564 "0".into()
565 } else if abs >= 100.0 {
566 format!("{:.1}", v)
567 } else if abs >= 1.0 {
568 format!("{:.2}", v)
569 } else if abs >= 0.01 {
570 format!("{:.3}", v)
571 } else {
572 format!("{:.4}", v)
573 }
574}
575
576pub fn format_xp_delta(v: f64) -> String {
578 let abs = v.abs();
579 if abs == 0.0 {
580 "0".into()
581 } else if abs >= 1.0 {
582 format!("{:+.2}", v)
583 } else if abs >= 0.01 {
584 format!("{:+.3}", v)
585 } else {
586 format!("{:+.4}", v)
587 }
588}
589
590pub fn format_progress_pct(progress: f32) -> String {
591 let pct = (progress * 100.0) as f64;
592 if pct >= 10.0 {
593 format!("{pct:.0}%")
594 } else if pct >= 0.01 {
595 format!("{pct:.2}%")
596 } else if pct > 0.0 {
597 format!("{pct:.3}%")
598 } else {
599 "0%".into()
600 }
601}
602
603pub fn format_pool_xp(pool: f64, above_baseline: f64) -> String {
604 if above_baseline.abs() > 0.0001 {
605 format!(
606 "{} ({})",
607 format_xp_value(pool),
608 format_xp_delta(above_baseline)
609 )
610 } else {
611 format_xp_value(pool)
612 }
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618 use flatland_protocol::{PlayerSkills, PrimaryAttributes, ProgressionXp};
619
620 #[test]
621 fn tiny_xp_values_are_not_rounded_to_zero() {
622 assert_eq!(format_xp_value(0.08), "0.080");
623 assert_eq!(format_xp_delta(0.08), "+0.080");
624 assert_eq!(
625 format_xp_band(0.08, 58.65, 58.57, 16),
626 "0.080/58.65 XP · 58.57 to 16"
627 );
628 assert_eq!(format_xp_band_compact(0.08, 58.65), "0.080/58.65");
629 assert_eq!(format_progress_pct(0.0014), "0.14%");
630 }
631
632 #[test]
633 fn bootstrap_band_is_zero_until_first_gain() {
634 let curve = ProgressionCurve::default();
635 let xp = ProgressionXp::bootstrap_new(15, curve.xp_base, curve.xp_growth);
636 let attrs = PrimaryAttributes::default();
637 let sheet = build_character_sheet(&minimal_state(attrs, xp.clone())).attributes;
638 let str_row = sheet.iter().find(|r| r.label == "STR").unwrap();
639 assert!(str_row.xp_into_band.abs() < 0.001);
640
641 let mut gained = xp;
642 gained.strength += 0.08;
643 let sheet2 = build_character_sheet(&minimal_state(attrs, gained)).attributes;
644 let str2 = sheet2.iter().find(|r| r.label == "STR").unwrap();
645 assert!((str2.xp_into_band - 0.08).abs() < 0.001);
646 assert!(str2.progress > 0.0);
647 }
648
649 #[test]
650 fn learned_abilities_use_friendly_labels() {
651 let curve = ProgressionCurve::default();
652 let xp = ProgressionXp::bootstrap_new(15, curve.xp_base, curve.xp_growth);
653 let attrs = PrimaryAttributes::default();
654 let mut state = minimal_state(attrs, xp);
655 state.known_abilities = vec!["unarmed".into(), "heal_touch".into(), "fireball".into()];
656 let sheet = build_character_sheet(&state);
657 assert_eq!(
658 sheet.learned_abilities,
659 vec![
660 "Unarmed".to_string(),
661 "Heal Touch".into(),
662 "Fireball".into()
663 ]
664 );
665 }
666
667 #[test]
668 fn harvest_diverges_strength_from_intelligence() {
669 let curve = ProgressionCurve::default();
670 let mut xp = ProgressionXp::bootstrap_new(15, curve.xp_base, curve.xp_growth);
671 xp.strength += 1.0;
672 let attrs = PrimaryAttributes::default();
673 let rows = build_character_sheet(&minimal_state(attrs, xp)).attributes;
674 let str_row = rows.iter().find(|r| r.label == "STR").unwrap();
675 let int_row = rows.iter().find(|r| r.label == "INT").unwrap();
676 assert!(str_row.above_baseline > int_row.above_baseline);
677 assert!((str_row.pool_xp - int_row.pool_xp - 1.0).abs() < 0.01);
678 }
679
680 #[test]
681 fn move_speed_display_includes_load_percent_when_slow() {
682 let curve = ProgressionCurve::default();
683 let xp = ProgressionXp::bootstrap_new(15, curve.xp_base, curve.xp_growth);
684 let mut state = minimal_state(PrimaryAttributes::default(), xp);
685 state.move_speed_mps = 3.2;
686 state.move_speed_mult = 0.70;
687 let sheet = build_character_sheet(&state);
688 assert_eq!(sheet.move_speed_display(), "3.2 m/s (70% walk)");
689 }
690
691 fn minimal_state(attrs: PrimaryAttributes, xp: ProgressionXp) -> GameState {
692 use flatland_protocol::{EntityState, Transform, Velocity2D, WorldCoord};
693 let mut state = GameState {
694 session_id: 1,
695 entity_id: 1,
696 character_id: None,
697 tick: 0,
698 chunk_rev: 0,
699 content_rev: 0,
700 publish_rev: 0,
701 entities: Vec::new(),
702 player: None,
703 resource_nodes: Vec::new(),
704 ground_drops: Vec::new(),
705 placed_containers: Vec::new(),
706 buildings: Vec::new(),
707 doors: Vec::new(),
708 interior_map: None,
709 npcs: Vec::new(),
710 blueprints: Vec::new(),
711 building_materials: Vec::new(),
712 world_x0: 0.0,
713 world_y0: 0.0,
714 world_width_m: 256.0,
715 world_height_m: 256.0,
716 terrain_zones: Vec::new(),
717 z_platforms: Vec::new(),
718 z_transitions: Vec::new(),
719 z_bands_outdoor_backup: None,
720 world_clock: flatland_protocol::WorldClock::default(),
721 inventory: std::collections::HashMap::new(),
722 inventory_hints: std::collections::HashMap::new(),
723 item_catalog: std::collections::HashMap::new(),
724 logs: std::collections::VecDeque::new(),
725 intents_sent: 0,
726 ticks_received: 0,
727 connected: true,
728 disconnect_reason: None,
729 show_stats: false,
730 hud_log_hidden: false,
731 show_equip_menu: false,
732 equip_menu_index: 0,
733 show_craft_menu: false,
734 show_plot_build_menu: false,
735 plot_build_focus_wall: true,
736 plot_build_wall_index: 0,
737 plot_build_roof_index: 0,
738 craft_menu_index: 0,
739 craft_batch_quantity: 1,
740 craft_tab: crate::CraftTab::Ready,
741 craft_filter: String::new(),
742 craft_filter_focused: false,
743 craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
744 show_shop_menu: false,
745 shop_catalog: None,
746 bank_panel: None,
747 bank_menu_index: 0,
748 bank_ui_mode: crate::BankUiMode::Menu,
749 storage_panel: None,
750 market_panel: None,
751 market_menu_index: 0,
752 market_filter: String::new(),
753 market_filter_focused: false,
754 market_category_filter: None,
755 market_buy_confirm: None,
756 market_ui_mode: crate::MarketUiMode::Browse,
757 storage_menu_index: 0,
758 storage_ui_mode: crate::StorageUiMode::Menu,
759 shop_tab: crate::ShopTab::Buy,
760 shop_menu_index: 0,
761 shop_quantity: 1,
762 shop_trade_log: std::collections::VecDeque::new(),
763 show_npc_verb_menu: false,
764 npc_verb_target: None,
765 npc_verb_index: 0,
766 npc_verb_notice: None,
767 player_verbs: Default::default(),
768 social_chat: Default::default(),
769 trade_ui: Default::default(),
770 whisper_pouch_ui: Default::default(),
771 show_npc_chat: false,
772 npc_chat: None,
773 show_inventory_menu: false,
774 inventory_menu_index: 0,
775 inventory_tab: crate::InventoryTab::OnPerson,
776 inventory_filter: String::new(),
777 inventory_filter_focused: false,
778 show_move_picker: false,
779 move_picker_index: 0,
780 move_picker: None,
781 show_grant_picker: false,
782 grant_picker_index: 0,
783 grant_picker: None,
784 show_destroy_picker: false,
785 destroy_confirm_pending: false,
786 destroy_picker: None,
787 show_rename_prompt: false,
788 rename_plot_id: None,
789 highlighted_plot_id: None,
790 show_worker_rename: false,
791 rename_buffer: String::new(),
792 combat_target: None,
793 combat_target_label: None,
794 ground_target: None,
795 combat_fx: Vec::new(),
796 ground_hazards: Vec::new(),
797 property_zones: Vec::new(),
798 tax_zones: Vec::new(),
799 growth_zones: Vec::new(),
800 biome_zones: Vec::new(),
801 terrain_kind_nav: Vec::new(),
802 property_plots: Vec::new(),
803 property_plot_settings: None,
804 claim_mode: None,
805 relocate_mode: None,
806 sell_plot_confirm: None,
807 sell_plot_armed_at: None,
808 show_plant_menu: false,
809 plant_menu_index: 0,
810 show_farm_access: false,
811 farm_access_name_draft: String::new(),
812 farm_access_discount_bps: 0,
813 farm_access_index: 0,
814 plant_quantity: 1,
815 in_combat: false,
816 auto_attack: true,
817 combat_has_los: false,
818 attack_cd_ticks: 0,
819 gcd_ticks: 0,
820 weapon_ability_id: String::new(),
821 mainhand_template_id: None,
822 mainhand_label: None,
823 mainhand_instance_id: None,
824 offhand_template_id: None,
825 offhand_label: None,
826 offhand_instance_id: None,
827 mainhand_hand_slots: 1,
828 defense: None,
829 worn: std::collections::BTreeMap::new(),
830 carry_mass: 0.0,
831 carry_mass_max: 37.5,
832 encumbrance: flatland_protocol::EncumbranceState::Light,
833 move_speed_mps: 4.5,
834 move_speed_mult: 1.0,
835 inventory_stacks: Vec::new(),
836 keychain_stacks: Vec::new(),
837 whisper_pouch_stacks: Vec::new(),
838 combat_target_detail: None,
839 statuses: Vec::new(),
840 cast_progress: None,
841 timed_channel: None,
842 plot_build_offer: None,
843 ability_cooldowns: Vec::new(),
844 blocking_active: false,
845 max_target_slots: 1,
846 combat_slots: Vec::new(),
847 rotation_presets: Vec::new(),
848 known_abilities: Vec::new(),
849 ability_meta: std::collections::HashMap::new(),
850 ability_mastery: std::collections::HashMap::new(),
851 hotbar: vec![None; 9],
852 max_abilities_per_rotation: 0,
853 show_loadout_menu: false,
854 show_keychain_menu: false,
855 keychain_menu_index: 0,
856 show_rotation_editor: false,
857 rotation_editor: crate::RotationEditorState::default(),
858 show_quest_menu: false,
859 quest_menu_index: 0,
860 quest_withdraw_confirm: false,
861 hired_workers: Vec::new(),
862 show_workers_menu: false,
863 workers_menu_index: 0,
864 worker_dismiss_confirmation: None,
865 workers_menu_compact: false,
866 worker_step_display: std::collections::BTreeMap::new(),
867 worker_error_display: std::collections::BTreeMap::new(),
868 worker_health_ring_until: std::collections::BTreeMap::new(),
869 pending_worker_hire_since: None,
870 show_worker_give_picker: false,
871 worker_give_picker_index: 0,
872 worker_give_picker: None,
873 show_worker_give_target_picker: false,
874 worker_give_target_picker_index: 0,
875 worker_give_target_picker: None,
876 show_worker_take_picker: false,
877 worker_take_picker_index: 0,
878 worker_take_picker: None,
879 show_worker_teach_picker: false,
880 worker_teach_picker_index: 0,
881 worker_teach_picker: None,
882 worker_route_editor: None,
883 progression_curve: None,
884 show_quest_offer: false,
885 pending_quest_offers: Vec::new(),
886 quest_offer_index: 0,
887 interactables: Vec::new(),
888 ledger: None,
889 career: None,
890 character_sheet_tab: crate::CharacterSheetTab::Character,
891 ledger_period: crate::LedgerPeriod::Day,
892 quest_log: Vec::new(),
893 harvest_in_progress: false,
894 harvest_started_at: None,
895 pending_craft_ack: None,
896 craft_channel_blueprint_id: None,
897 pending_worker_job_ack: None,
898 attending_worker_instance_id: None,
899 loadout_menu_index: 0,
900 loadout_hotbar_slot: 1,
901 loadout_ability_index: 0,
902 loadout_focus_presets: false,
903 };
904 state.player = Some(EntityState {
905 id: 1,
906 transform: Transform {
907 position: WorldCoord::surface(0.0, 0.0),
908 yaw: 0.0,
909 velocity: Velocity2D { vx: 0.0, vy: 0.0 },
910 },
911 label: "Hero".into(),
912 vitals: Some(flatland_protocol::PlayerVitals::from_attributes(attrs)),
913 attributes: Some(attrs),
914 skills: Some(PlayerSkills::default()),
915 inside_building: None,
916 tile_id: None,
917 paperdoll_ref: None,
918 draw_scale: 1.0,
919 presentation_state: None,
920 sprite_mode: None,
921 progression_xp: Some(xp),
922 combat_cues: vec![],
923 statuses: vec![],
924 });
925 state
926 }
927}