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