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