use flatland_protocol::{
EncumbranceState, LifeState, PrimaryAttributes, ProgressionCurve,
PRIMARY_STAT_ROWS, SKILL_ROWS,
};
use crate::{body_slot_label, GameState};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SheetSync {
Full,
MissingXpPools,
}
#[derive(Debug, Clone)]
pub struct CharacterSheet {
pub sync: SheetSync,
pub name: String,
pub position: (f32, f32, f32),
pub inside_building: Option<String>,
pub life: String,
pub deaths: u32,
pub money: String,
pub attributes: Vec<AttributeRow>,
pub skills: Vec<SkillRow>,
pub derived_attack: f32,
pub derived_spell: f32,
pub derived_evasion: f32,
pub derived_carry_kg: f32,
pub derived_sight_m: f32,
pub derived_fov_deg: f32,
pub pools: Vec<PoolRow>,
pub carry_mass: f32,
pub carry_mass_max: f32,
pub encumbrance: &'static str,
pub mainhand: String,
pub offhand: String,
pub defense_summary: Option<String>,
pub worn: Vec<(String, String)>,
pub active_statuses: Vec<String>,
pub keychain_count: usize,
pub in_combat: bool,
pub auto_attack: bool,
pub blocking: bool,
pub target_slots: u8,
pub has_los: bool,
pub target_label: Option<String>,
pub weapon_ability: Option<String>,
pub rotation_summary: Option<String>,
pub blueprint_count: usize,
pub footer_hints: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct AttributeRow {
pub label: &'static str,
pub level: u16,
pub next_level: u16,
pub progress: f32,
pub xp_into_band: f64,
pub xp_band_size: f64,
pub xp_to_next: f64,
pub pool_xp: f64,
pub above_baseline: f64,
}
#[derive(Debug, Clone)]
pub struct SkillRow {
pub name: &'static str,
pub tier: u16,
pub next_tier: u16,
pub progress: f32,
pub xp_into_band: f64,
pub xp_band_size: f64,
pub xp_to_next: f64,
pub pool_xp: f64,
}
#[derive(Debug, Clone)]
pub struct PoolRow {
pub label: &'static str,
pub current: f32,
pub max: f32,
}
pub fn build_character_sheet(state: &GameState) -> CharacterSheet {
let curve = state.progression_curve.unwrap_or_default();
let bootstrap_primary = curve.xp_for_display_level(curve.baseline_display as f64);
let player = state.player.as_ref();
let attrs = player.and_then(|p| p.attributes);
let skills = player.and_then(|p| p.skills.as_ref());
let xp = player.and_then(|p| p.progression_xp.as_ref());
let sync = if xp.is_some() {
SheetSync::Full
} else {
SheetSync::MissingXpPools
};
let attributes = if let (Some(attrs), Some(xp)) = (attrs, xp) {
PRIMARY_STAT_ROWS
.iter()
.map(|(label, pool)| {
let pool_xp = pool(xp);
let level = PrimaryAttributes::display(flatland_protocol::primary_internal(
&attrs, label,
));
let next_level = level.saturating_add(1);
let prog = curve.band_progress(pool_xp, level, next_level);
AttributeRow {
label,
level,
next_level: prog.next,
progress: prog.progress as f32,
xp_into_band: prog.xp_into_level,
xp_band_size: (prog.xp_ceiling - prog.xp_floor).max(0.0),
xp_to_next: prog.xp_to_next,
pool_xp,
above_baseline: pool_xp - bootstrap_primary,
}
})
.collect()
} else if let Some(attrs) = attrs {
PRIMARY_STAT_ROWS
.iter()
.map(|(label, _)| AttributeRow {
label,
level: PrimaryAttributes::display(flatland_protocol::primary_internal(
&attrs, label,
)),
next_level: 0,
progress: 0.0,
xp_into_band: 0.0,
xp_band_size: 0.0,
xp_to_next: 0.0,
pool_xp: 0.0,
above_baseline: 0.0,
})
.collect()
} else {
Vec::new()
};
let skills = if let (Some(skills), Some(xp)) = (skills, xp) {
let mut rows: Vec<SkillRow> = SKILL_ROWS
.iter()
.map(|(name, pool, tier_fn)| {
let pool_xp = pool(xp);
let tier = tier_fn(skills);
let next_tier = tier.saturating_add(1).min(10);
let current_display = if tier == 0 { 0 } else { tier.saturating_mul(10) };
let next_display = if next_tier >= 10 {
100
} else {
next_tier.saturating_mul(10)
};
let prog = curve.band_progress(pool_xp, current_display, next_display);
SkillRow {
name,
tier,
next_tier,
progress: prog.progress as f32,
xp_into_band: prog.xp_into_level,
xp_band_size: (prog.xp_ceiling - prog.xp_floor).max(0.0),
xp_to_next: prog.xp_to_next,
pool_xp,
}
})
.collect();
rows.sort_by(|a, b| {
b.pool_xp
.partial_cmp(&a.pool_xp)
.unwrap_or(std::cmp::Ordering::Equal)
});
rows
} else if let Some(skills) = skills {
SKILL_ROWS
.iter()
.map(|(name, _, tier_fn)| SkillRow {
name,
tier: tier_fn(skills),
next_tier: 0,
progress: 0.0,
xp_into_band: 0.0,
xp_band_size: 0.0,
xp_to_next: 0.0,
pool_xp: 0.0,
})
.collect()
} else {
Vec::new()
};
let derived = attrs.map(|a| a.derived_preview());
let pools = state
.vitals()
.map(|v| {
vec![
PoolRow {
label: "Health",
current: v.health,
max: v.health_max,
},
PoolRow {
label: "Mana",
current: v.mana,
max: v.mana_max,
},
PoolRow {
label: "Stamina",
current: v.stamina,
max: v.stamina_max,
},
PoolRow {
label: "Hunger",
current: v.hunger,
max: v.hunger_max,
},
PoolRow {
label: "Thirst",
current: v.thirst,
max: v.thirst_max,
},
]
})
.unwrap_or_default();
let (px, py, pz) = state.player_position_with_z();
let mut footer_hints = training_hints(&attributes, sync, &curve);
if let Some(top) = most_trained_attribute(&attributes) {
if top.above_baseline > 0.0001 {
footer_hints.insert(
0,
format!(
"Most trained primary: {} ({} above baseline)",
top.label,
format_xp_delta(top.above_baseline)
),
);
}
}
if let Some(top) = skills.iter().find(|s| s.pool_xp > 0.0001) {
footer_hints.insert(
0,
format!(
"Most trained skill: {} ({} XP)",
top.name,
format_xp_value(top.pool_xp)
),
);
}
CharacterSheet {
sync,
name: player.map(|p| p.label.clone()).unwrap_or_else(|| "—".into()),
position: (px, py, pz),
inside_building: player.and_then(|p| p.inside_building.clone()),
life: state
.vitals()
.map(|v| match v.life_state {
LifeState::Alive => "alive".into(),
LifeState::Dead => "dead".into(),
})
.unwrap_or_else(|| "—".into()),
deaths: state.vitals().map(|v| v.deaths).unwrap_or(0),
money: state.currency_display(),
attributes,
skills,
derived_attack: derived.map(|d| d.attack_power).unwrap_or(0.0),
derived_spell: derived.map(|d| d.spell_power).unwrap_or(0.0),
derived_evasion: derived.map(|d| d.evasion).unwrap_or(0.0),
derived_carry_kg: derived.map(|d| d.carry_mass_max).unwrap_or(state.carry_mass_max),
derived_sight_m: derived.map(|d| d.sight_range_m).unwrap_or(0.0),
derived_fov_deg: derived.map(|d| d.fov_deg).unwrap_or(0.0),
pools,
carry_mass: state.carry_mass,
carry_mass_max: state.carry_mass_max,
encumbrance: match state.encumbrance {
EncumbranceState::Light => "Light",
EncumbranceState::Heavy => "Heavy",
EncumbranceState::Over => "OVER",
},
mainhand: state
.mainhand_label
.clone()
.or_else(|| state.mainhand_template_id.clone())
.unwrap_or_else(|| "(unarmed)".into()),
offhand: if state.mainhand_hand_slots >= 2 {
"(locked — 2H)".into()
} else {
state
.offhand_label
.clone()
.or_else(|| state.offhand_template_id.clone())
.unwrap_or_else(|| "(empty)".into())
},
defense_summary: state.defense.as_ref().map(|d| {
format!(
"armor {:.0} · VIT {:.0} · DR {:.0}% · press p for Equip",
d.armor_physical,
d.vitality_contribution,
d.estimated_physical_dr * 100.0
)
}),
worn: state
.worn
.iter()
.map(|(slot, stack)| {
let label = stack
.display_name
.clone()
.unwrap_or_else(|| stack.template_id.clone());
let bindings = crate::format_status_bindings_suffix(
&stack.status_bindings,
state.tick,
crate::DEFAULT_TICK_HZ,
);
(body_slot_label(*slot).to_string(), format!("{label}{bindings}"))
})
.collect(),
active_statuses: state
.statuses
.iter()
.map(|s| {
let ttl = s
.remaining_sec
.map(|sec| {
if sec >= 120.0 {
format!(" · {:.0}m", sec / 60.0)
} else {
format!(" · {sec:.0}s")
}
})
.unwrap_or_default();
format!("{}{ttl}", s.label)
})
.collect(),
keychain_count: state.keychain_stacks.len(),
in_combat: state.in_combat,
auto_attack: state.auto_attack,
blocking: state.blocking_active,
target_slots: state.max_target_slots,
has_los: state.combat_has_los,
target_label: state.combat_target_label.clone().or_else(|| {
state
.combat_target
.map(|id| format!("entity {id}"))
}),
weapon_ability: (!state.weapon_ability_id.is_empty())
.then(|| state.weapon_ability_id.clone()),
rotation_summary: (!state.combat_slots.is_empty()).then(|| {
state
.combat_slots
.iter()
.map(|s| {
format!(
"T{}={}",
s.slot_index,
s.preset_id.as_deref().unwrap_or("—")
)
})
.collect::<Vec<_>>()
.join(" ")
}),
blueprint_count: state.blueprints.len(),
footer_hints,
}
}
fn most_trained_attribute<'a>(rows: &'a [AttributeRow]) -> Option<&'a AttributeRow> {
rows.iter()
.max_by(|a, b| {
a.above_baseline
.partial_cmp(&b.above_baseline)
.unwrap_or(std::cmp::Ordering::Equal)
})
.filter(|r| r.above_baseline > 0.0)
}
fn training_hints(attributes: &[AttributeRow], sync: SheetSync, curve: &ProgressionCurve) -> Vec<String> {
let mut hints = Vec::new();
match sync {
SheetSync::MissingXpPools => {
hints.push("XP pools missing on wire — reconnect after updating server/client.".into());
}
SheetSync::Full => {
let band = curve.xp_for_display_level((curve.baseline_display + 1) as f64)
- curve.xp_for_display_level(curve.baseline_display as f64);
hints.push(format!(
"Progression is slow by design: ~{:.0} XP per +1 stat at level {} (~{:.0} harvests).",
band,
curve.baseline_display,
band / 0.08
));
hints.push(
"New characters start with equal primaries. Harvest → STR/STA/Logging; craft → DEX/INT/Crafting; combat → STR/DEX/Swords.".into(),
);
let diverged = attributes
.iter()
.filter(|r| r.above_baseline.abs() > 0.0001)
.count();
if diverged == 0 {
hints.push(
"No stat has moved off baseline yet — keep harvesting, crafting, or fighting to diverge pools.".into(),
);
}
}
}
hints
}
pub fn format_xp_band_compact(into: f64, band: f64) -> String {
if band <= 0.0 {
return "—".into();
}
format!(
"{}/{}",
format_xp_value(into),
format_xp_value(band)
)
}
pub fn format_xp_band(into: f64, band: f64, to_next: f64, next: u16) -> String {
if band <= 0.0 {
return "—".into();
}
format!(
"{}/{} XP · {} to {next}",
format_xp_value(into),
format_xp_value(band),
format_xp_value(to_next),
)
}
pub fn format_xp_value(v: f64) -> String {
let abs = v.abs();
if abs == 0.0 {
"0".into()
} else if abs >= 100.0 {
format!("{:.1}", v)
} else if abs >= 1.0 {
format!("{:.2}", v)
} else if abs >= 0.01 {
format!("{:.3}", v)
} else {
format!("{:.4}", v)
}
}
pub fn format_xp_delta(v: f64) -> String {
let abs = v.abs();
if abs == 0.0 {
"0".into()
} else if abs >= 1.0 {
format!("{:+.2}", v)
} else if abs >= 0.01 {
format!("{:+.3}", v)
} else {
format!("{:+.4}", v)
}
}
pub fn format_progress_pct(progress: f32) -> String {
let pct = (progress * 100.0) as f64;
if pct >= 10.0 {
format!("{pct:.0}%")
} else if pct >= 0.01 {
format!("{pct:.2}%")
} else if pct > 0.0 {
format!("{pct:.3}%")
} else {
"0%".into()
}
}
pub fn format_pool_xp(pool: f64, above_baseline: f64) -> String {
if above_baseline.abs() > 0.0001 {
format!(
"{} ({})",
format_xp_value(pool),
format_xp_delta(above_baseline)
)
} else {
format_xp_value(pool)
}
}
#[cfg(test)]
mod tests {
use super::*;
use flatland_protocol::{PlayerSkills, PrimaryAttributes, ProgressionXp};
#[test]
fn tiny_xp_values_are_not_rounded_to_zero() {
assert_eq!(format_xp_value(0.08), "0.080");
assert_eq!(format_xp_delta(0.08), "+0.080");
assert_eq!(format_xp_band(0.08, 58.65, 58.57, 16), "0.080/58.65 XP · 58.57 to 16");
assert_eq!(format_xp_band_compact(0.08, 58.65), "0.080/58.65");
assert_eq!(format_progress_pct(0.0014), "0.14%");
}
#[test]
fn bootstrap_band_is_zero_until_first_gain() {
let curve = ProgressionCurve::default();
let xp = ProgressionXp::bootstrap_new(15, curve.xp_base, curve.xp_growth);
let attrs = PrimaryAttributes::default();
let sheet = build_character_sheet(&minimal_state(attrs, xp.clone())).attributes;
let str_row = sheet.iter().find(|r| r.label == "STR").unwrap();
assert!(str_row.xp_into_band.abs() < 0.001);
let mut gained = xp;
gained.strength += 0.08;
let sheet2 = build_character_sheet(&minimal_state(attrs, gained)).attributes;
let str2 = sheet2.iter().find(|r| r.label == "STR").unwrap();
assert!((str2.xp_into_band - 0.08).abs() < 0.001);
assert!(str2.progress > 0.0);
}
#[test]
fn harvest_diverges_strength_from_intelligence() {
let curve = ProgressionCurve::default();
let mut xp = ProgressionXp::bootstrap_new(15, curve.xp_base, curve.xp_growth);
xp.strength += 1.0;
let attrs = PrimaryAttributes::default();
let rows = build_character_sheet(&minimal_state(attrs, xp)).attributes;
let str_row = rows.iter().find(|r| r.label == "STR").unwrap();
let int_row = rows.iter().find(|r| r.label == "INT").unwrap();
assert!(str_row.above_baseline > int_row.above_baseline);
assert!((str_row.pool_xp - int_row.pool_xp - 1.0).abs() < 0.01);
}
fn minimal_state(attrs: PrimaryAttributes, xp: ProgressionXp) -> GameState {
use flatland_protocol::{EntityState, Transform, Velocity2D, WorldCoord};
let mut state = GameState {
session_id: 1,
entity_id: 1,
character_id: None,
tick: 0,
chunk_rev: 0,
content_rev: 0,
publish_rev: 0,
entities: Vec::new(),
player: None,
resource_nodes: Vec::new(),
ground_drops: Vec::new(),
placed_containers: Vec::new(),
buildings: Vec::new(),
doors: Vec::new(),
interior_map: None,
npcs: Vec::new(),
blueprints: Vec::new(),
world_width_m: 256.0,
world_height_m: 256.0,
terrain_zones: Vec::new(),
z_platforms: Vec::new(),
z_transitions: Vec::new(),
world_clock: flatland_protocol::WorldClock::default(),
inventory: std::collections::HashMap::new(),
inventory_hints: std::collections::HashMap::new(),
logs: std::collections::VecDeque::new(),
intents_sent: 0,
ticks_received: 0,
connected: true,
disconnect_reason: None,
show_stats: false,
hud_log_hidden: false,
show_equip_menu: false,
equip_menu_index: 0,
show_craft_menu: false,
craft_menu_index: 0,
craft_batch_quantity: 1,
show_shop_menu: false,
shop_catalog: None,
bank_panel: None,
bank_menu_index: 0,
bank_ui_mode: crate::BankUiMode::Menu,
storage_panel: None,
storage_menu_index: 0,
storage_ui_mode: crate::StorageUiMode::Menu,
shop_tab: crate::ShopTab::Buy,
shop_menu_index: 0,
shop_quantity: 1,
shop_trade_log: std::collections::VecDeque::new(),
show_npc_verb_menu: false,
npc_verb_target: None,
npc_verb_index: 0,
player_verbs: Default::default(),
social_chat: Default::default(),
trade_ui: Default::default(),
whisper_pouch_ui: Default::default(),
show_npc_chat: false,
npc_chat: None,
show_inventory_menu: false,
inventory_menu_index: 0,
inventory_tab: crate::InventoryTab::OnPerson,
inventory_filter: String::new(),
inventory_filter_focused: false,
show_move_picker: false,
move_picker_index: 0,
move_picker: None,
show_grant_picker: false,
grant_picker_index: 0,
grant_picker: None,
show_destroy_picker: false,
destroy_confirm_pending: false,
destroy_picker: None,
show_rename_prompt: false,
show_worker_rename: false,
rename_buffer: String::new(),
combat_target: None,
combat_target_label: None,
combat_fx: Vec::new(),
in_combat: false,
auto_attack: true,
combat_has_los: false,
attack_cd_ticks: 0,
gcd_ticks: 0,
weapon_ability_id: String::new(),
mainhand_template_id: None,
mainhand_label: None,
offhand_template_id: None,
offhand_label: None,
mainhand_hand_slots: 1,
defense: None,
worn: std::collections::BTreeMap::new(),
carry_mass: 0.0,
carry_mass_max: 37.5,
encumbrance: flatland_protocol::EncumbranceState::Light,
inventory_stacks: Vec::new(),
keychain_stacks: Vec::new(),
whisper_pouch_stacks: Vec::new(),
combat_target_detail: None,
statuses: Vec::new(),
cast_progress: None,
ability_cooldowns: Vec::new(),
blocking_active: false,
max_target_slots: 1,
combat_slots: Vec::new(),
rotation_presets: Vec::new(),
known_abilities: Vec::new(),
hotbar: vec![None; 9],
max_abilities_per_rotation: 0,
show_loadout_menu: false,
show_keychain_menu: false,
keychain_menu_index: 0,
show_rotation_editor: false,
rotation_editor: crate::RotationEditorState::default(),
show_quest_menu: false,
quest_menu_index: 0,
quest_withdraw_confirm: false,
hired_workers: Vec::new(),
show_workers_menu: false,
workers_menu_index: 0,
workers_menu_compact: false,
worker_step_display: std::collections::BTreeMap::new(),
worker_error_display: std::collections::BTreeMap::new(),
show_worker_give_picker: false,
worker_give_picker_index: 0,
worker_give_picker: None,
show_worker_give_target_picker: false,
worker_give_target_picker_index: 0,
worker_give_target_picker: None,
show_worker_take_picker: false,
worker_take_picker_index: 0,
worker_take_picker: None,
show_worker_teach_picker: false,
worker_teach_picker_index: 0,
worker_teach_picker: None,
worker_route_editor: None,
progression_curve: None,
show_quest_offer: false,
pending_quest_offer: None,
interactables: Vec::new(),
ledger: None,
career: None,
character_sheet_tab: crate::CharacterSheetTab::Character,
ledger_period: crate::LedgerPeriod::Day,
quest_log: Vec::new(),
harvest_in_progress: false,
harvest_started_at: None,
pending_craft_ack: None,
pending_worker_job_ack: None,
loadout_menu_index: 0,
loadout_hotbar_slot: 1,
loadout_ability_index: 0,
loadout_focus_presets: false,
};
state.player = Some(EntityState {
id: 1,
transform: Transform {
position: WorldCoord::surface(0.0, 0.0),
yaw: 0.0,
velocity: Velocity2D { vx: 0.0, vy: 0.0 },
},
label: "Hero".into(),
vitals: Some(flatland_protocol::PlayerVitals::from_attributes(attrs)),
attributes: Some(attrs),
skills: Some(PlayerSkills::default()),
inside_building: None,
tile_id: None,
paperdoll_ref: None,
presentation_state: None,
sprite_mode: None,
progression_xp: Some(xp),
});
state
}
}