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