use std::collections::VecDeque;
use flatland_protocol::{
BlueprintView, BuildingView, DoorView, EntityId, EntityState, Intent, LifeState, NpcView,
Seq, SessionId, Tick,
};
use crate::session::{PlayConnection, SessionEvent};
const MAX_LOG_LINES: usize = 200;
const INTERACTION_RADIUS_M: f32 = 1.5;
const DOOR_INTERACTION_RADIUS_M: f32 = 3.5;
#[derive(Debug, Clone)]
pub struct GameState {
pub session_id: SessionId,
pub entity_id: EntityId,
pub tick: Tick,
pub chunk_rev: u64,
pub entities: Vec<EntityState>,
pub player: Option<EntityState>,
pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
pub buildings: Vec<BuildingView>,
pub doors: Vec<DoorView>,
pub npcs: Vec<NpcView>,
pub blueprints: Vec<BlueprintView>,
pub world_width_m: f32,
pub world_height_m: f32,
pub inventory: std::collections::HashMap<String, u32>,
pub logs: VecDeque<String>,
pub intents_sent: u64,
pub ticks_received: u64,
pub connected: bool,
pub show_stats: bool,
pub show_craft_menu: bool,
pub craft_menu_index: usize,
}
impl GameState {
pub fn push_log(&mut self, line: impl Into<String>) {
self.logs.push_back(line.into());
while self.logs.len() > MAX_LOG_LINES {
self.logs.pop_front();
}
}
pub fn is_alive(&self) -> bool {
self.player
.as_ref()
.and_then(|p| p.vitals)
.map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
.unwrap_or(true)
}
pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
self.player.as_ref().and_then(|p| p.vitals)
}
pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
blueprint.inputs.iter().all(|input| {
self.inventory
.get(&input.template_id)
.copied()
.unwrap_or(0)
>= input.quantity
}) && blueprint.required_tools.iter().all(|tool| {
self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1
})
}
pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
if self.can_craft_blueprint(blueprint) {
return None;
}
let mut missing = Vec::new();
for input in &blueprint.inputs {
let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
if have < input.quantity {
missing.push(format!("{}×{} (have {have})", input.quantity, input.template_id));
}
}
for tool in &blueprint.required_tools {
let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
if have < 1 {
missing.push(format!("tool: {}", tool.item));
}
}
if missing.is_empty() {
None
} else {
Some(missing.join(", "))
}
}
pub fn player_entity(&self) -> Option<&EntityState> {
self.player
.as_ref()
.or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
}
pub fn player_position(&self) -> (f32, f32) {
self.player_entity()
.map(|p| (p.transform.position.x, p.transform.position.y))
.unwrap_or((0.0, 0.0))
}
pub fn building_interior_at(&self, px: f32, py: f32) -> Option<&BuildingView> {
self.buildings
.iter()
.find(|b| point_in_building_interior(px, py, b))
}
pub fn effective_inside_building(&self) -> Option<String> {
let (px, py) = self.player_position();
if let Some(b) = self.building_interior_at(px, py) {
return Some(b.id.clone());
}
if is_instanced_world_coord(px, py, self.world_width_m, self.world_height_m) {
if let Some(id) = self
.player_entity()
.and_then(|p| p.inside_building.clone())
{
return Some(id);
}
if self.buildings.len() == 1 {
return Some(self.buildings[0].id.clone());
}
}
None
}
pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
self.inventory.clear();
for stack in stacks {
*self.inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
}
}
fn apply_snapshot_fields(&mut self, snapshot: &flatland_protocol::Snapshot, entity_id: EntityId) {
self.tick = snapshot.tick;
self.chunk_rev = snapshot.chunk_rev;
self.resource_nodes = snapshot.resource_nodes.clone();
self.world_width_m = snapshot.world_width_m;
self.world_height_m = snapshot.world_height_m;
self.buildings = snapshot.buildings.clone();
self.doors = snapshot.doors.clone();
self.npcs = snapshot.npcs.clone();
self.blueprints = snapshot.blueprints.clone();
self.sync_inventory_from_stacks(&snapshot.inventory);
self.player = snapshot
.entities
.iter()
.find(|e| e.id == entity_id)
.cloned();
self.entities = snapshot.entities.clone();
}
fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
self.tick = delta.tick;
if !delta.buildings.is_empty() {
self.buildings = delta.buildings.clone();
}
if !delta.blueprints.is_empty() {
self.blueprints = delta.blueprints.clone();
}
self.sync_inventory_from_stacks(&delta.inventory);
if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
self.player = Some(updated.clone());
}
self.entities = delta.entities.clone();
if self.player.is_none() {
self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
}
if let Some(player) = self.player.as_ref() {
let (px, py) = (player.transform.position.x, player.transform.position.y);
let stale_inside = player.inside_building.is_some()
&& self.building_interior_at(px, py).is_none()
&& !is_instanced_world_coord(px, py, self.world_width_m, self.world_height_m);
if stale_inside {
self.player.as_mut().unwrap().inside_building = None;
}
}
let outdoors = self.effective_inside_building().is_none();
if outdoors {
self.resource_nodes = delta.resource_nodes.clone();
} else if !delta.resource_nodes.is_empty() {
self.resource_nodes = delta.resource_nodes.clone();
}
if outdoors {
self.doors = delta.doors.clone();
self.npcs = delta.npcs.clone();
} else {
if !delta.doors.is_empty() {
self.doors = delta.doors.clone();
}
if !delta.npcs.is_empty() {
self.npcs = delta.npcs.clone();
}
}
}
}
fn point_in_building_interior(px: f32, py: f32, building: &BuildingView) -> bool {
px >= building.interior_origin_x
&& px <= building.interior_origin_x + building.width_m
&& py >= building.interior_origin_y
&& py <= building.interior_origin_y + building.depth_m
}
fn is_instanced_world_coord(px: f32, py: f32, world_w: f32, world_h: f32) -> bool {
if world_w > 0.0 && world_h > 0.0 {
px < 0.0 || py < 0.0 || px > world_w || py > world_h
} else {
px > 256.0 || py > 256.0
}
}
pub struct GameClient<S: PlayConnection> {
session: S,
seq: Seq,
pub state: GameState,
}
impl<S: PlayConnection> GameClient<S> {
pub fn new(session: S) -> Self {
let session_id = session.session_id();
let entity_id = session.entity_id();
Self {
session,
seq: 0,
state: GameState {
session_id,
entity_id,
tick: 0,
chunk_rev: 0,
entities: Vec::new(),
player: None,
resource_nodes: Vec::new(),
buildings: Vec::new(),
doors: Vec::new(),
npcs: Vec::new(),
blueprints: Vec::new(),
world_width_m: 0.0,
world_height_m: 0.0,
inventory: std::collections::HashMap::new(),
logs: VecDeque::new(),
intents_sent: 0,
ticks_received: 0,
connected: false,
show_stats: false,
show_craft_menu: false,
craft_menu_index: 0,
},
}
}
pub fn entity_id(&self) -> EntityId {
self.state.entity_id
}
pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
if self.state.connected {
return Ok(());
}
loop {
match self.session.next_event().await {
Some(SessionEvent::Welcome {
session_id,
entity_id,
snapshot,
}) => {
self.state.session_id = session_id;
self.state.entity_id = entity_id;
self.state.apply_snapshot_fields(&snapshot, entity_id);
self.state.connected = true;
self.state.push_log(format!(
"Connected — session {session_id}, entity {entity_id}"
));
return Ok(());
}
Some(SessionEvent::Disconnected { .. }) => {
anyhow::bail!("disconnected before welcome");
}
Some(_) => continue,
None => anyhow::bail!("session closed before welcome"),
}
}
}
pub fn drain_events(&mut self) {
while let Some(event) = self.session.try_next_event() {
if self.handle_event_sync(event).is_err() {
break;
}
}
}
pub async fn next_event(&mut self) -> Option<SessionEvent> {
self.session.next_event().await
}
pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
self.handle_event_sync(event)
}
fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
match event {
SessionEvent::Welcome {
session_id,
entity_id,
snapshot,
} => {
self.state.session_id = session_id;
self.state.entity_id = entity_id;
self.state.apply_snapshot_fields(&snapshot, entity_id);
self.state.connected = true;
}
SessionEvent::Tick(delta) => {
self.state.apply_tick_fields(&delta, self.state.entity_id);
self.state.ticks_received += 1;
}
SessionEvent::IntentAck { .. } => {}
SessionEvent::Chat(msg) => {
let label = match msg.channel {
flatland_protocol::ChatChannel::Nearby => "nearby",
flatland_protocol::ChatChannel::WhisperStone => "whisper",
};
self.state.push_log(format!(
"[{label}] {}: {}",
msg.from_name, msg.text
));
}
SessionEvent::HarvestResult(result) => {
*self
.state
.inventory
.entry(result.item_template.clone())
.or_insert(0) += result.quantity;
self.state.push_log(format!(
"Harvested {} x{}",
result.item_template, result.quantity
));
}
SessionEvent::CraftResult(result) => {
if let Some(output) = result.outputs.first() {
self.state.push_log(format!(
"Crafted {} x{}",
output.template_id, output.quantity
));
} else {
self.state.push_log(format!("Craft finished: {}", result.blueprint_id));
}
}
SessionEvent::Death(notice) => {
self.state.push_log(notice.message.clone());
self.state.push_log(format!(
"Respawned at ({:.1}, {:.1})",
notice.respawn_x, notice.respawn_y
));
}
SessionEvent::Interaction(notice) => {
self.state.push_log(notice.message.clone());
}
SessionEvent::Disconnected { reason } => {
self.state.connected = false;
if let Some(r) = reason.filter(|s| !s.is_empty()) {
self.state.push_log(format!("Disconnected: {r}"));
} else {
self.state.push_log("Disconnected from server");
}
}
}
Ok(())
}
pub fn is_connected(&self) -> bool {
self.state.connected
}
pub fn toggle_stats(&mut self) {
self.state.show_stats = !self.state.show_stats;
if self.state.show_stats {
self.state.show_craft_menu = false;
}
}
pub fn open_craft_menu(&mut self) {
self.state.show_craft_menu = true;
self.state.show_stats = false;
if self.state.blueprints.is_empty() {
self.state.craft_menu_index = 0;
return;
}
self.state.craft_menu_index = self
.state
.craft_menu_index
.min(self.state.blueprints.len() - 1);
if let Some(idx) = self
.state
.blueprints
.iter()
.position(|bp| self.state.can_craft_blueprint(bp))
{
self.state.craft_menu_index = idx;
}
}
pub fn close_craft_menu(&mut self) {
self.state.show_craft_menu = false;
}
pub fn craft_menu_move(&mut self, delta: i32) {
let n = self.state.blueprints.len();
if n == 0 {
return;
}
let idx = self.state.craft_menu_index as i32;
let next = (idx + delta).rem_euclid(n as i32);
self.state.craft_menu_index = next as usize;
}
pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
let Some(blueprint) = self
.state
.blueprints
.get(self.state.craft_menu_index)
.cloned()
else {
anyhow::bail!("no blueprints known");
};
if !self.state.can_craft_blueprint(&blueprint) {
let hint = self
.state
.craft_missing_hint(&blueprint)
.unwrap_or_else(|| "missing materials".into());
anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
}
self.craft(&blueprint.id).await?;
self.state.show_craft_menu = false;
Ok(())
}
pub async fn move_by(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
self.seq += 1;
self.session
.submit_intent(Intent::Move {
entity_id: self.state.entity_id,
forward,
strafe,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
if !self.state.connected {
anyhow::bail!("not connected");
}
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
let (px, py) = self
.state
.player
.as_ref()
.map(|p| (p.transform.position.x, p.transform.position.y))
.unwrap_or((0.0, 0.0));
let node_id = self
.state
.resource_nodes
.iter()
.filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
.min_by(|a, b| {
let da = distance(px, py, a.x, a.y);
let db = distance(px, py, b.x, b.y);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|n| n.id.clone())
.ok_or_else(|| anyhow::anyhow!("no harvestable nodes in view"))?;
self.seq += 1;
self.session
.submit_intent(Intent::Harvest {
entity_id: self.state.entity_id,
node_id,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
let blueprint_id = self
.state
.blueprints
.iter()
.find(|bp| self.state.can_craft_blueprint(bp))
.map(|bp| bp.id.clone())
.ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
self.craft(&blueprint_id).await
}
pub async fn craft(&mut self, blueprint_id: &str) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
self.seq += 1;
self.session
.submit_intent(Intent::Craft {
entity_id: self.state.entity_id,
blueprint_id: blueprint_id.to_string(),
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
let label = self
.state
.blueprints
.iter()
.find(|b| b.id == blueprint_id)
.map(|b| b.label.as_str())
.unwrap_or(blueprint_id);
self.state.push_log(format!("Crafting {label}…"));
Ok(())
}
pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
if !self.state.is_alive() {
anyhow::bail!("you are dead");
}
let target_id = self
.nearest_interact_target()
.ok_or_else(|| anyhow::anyhow!("nothing to interact with nearby"))?;
self.seq += 1;
self.session
.submit_intent(Intent::Interact {
entity_id: self.state.entity_id,
target_id: target_id.clone(),
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::TestDamage {
entity_id: self.state.entity_id,
amount,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn say(&mut self, channel: flatland_protocol::ChatChannel, text: &str) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::Say {
entity_id: self.state.entity_id,
channel,
text: text.to_string(),
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub async fn stop(&mut self) -> anyhow::Result<()> {
self.seq += 1;
self.session
.submit_intent(Intent::Stop {
entity_id: self.state.entity_id,
seq: self.seq,
})
.await?;
self.state.intents_sent += 1;
Ok(())
}
pub fn disconnect(&self) {
self.session.disconnect();
}
fn nearest_interact_target(&self) -> Option<String> {
let (px, py) = self.state.player_position();
let inside = self.state.effective_inside_building();
let door_in_range = |door: &DoorView| -> Option<(f32, String)> {
if inside.is_none() && door.is_exit {
return None;
}
if let Some(ref bid) = inside {
if door.building_id != *bid {
return None;
}
}
let d = distance(px, py, door.x, door.y);
if d <= DOOR_INTERACTION_RADIUS_M {
Some((d, door.id.clone()))
} else {
None
}
};
if inside.is_some() {
let mut best_exit: Option<(f32, String)> = None;
for door in &self.state.doors {
if !door.is_exit {
continue;
}
if let Some((d, id)) = door_in_range(door) {
match best_exit {
Some((bd, _)) if d >= bd => {}
_ => best_exit = Some((d, id)),
}
}
}
if let Some((_, id)) = best_exit {
return Some(id);
}
}
let mut best_door: Option<(f32, String)> = None;
for door in &self.state.doors {
if door.is_exit {
continue;
}
if let Some((d, id)) = door_in_range(door) {
match best_door {
Some((bd, _)) if d >= bd => {}
_ => best_door = Some((d, id)),
}
}
}
if let Some((_, id)) = best_door {
return Some(id);
}
let mut best_npc: Option<(f32, String)> = None;
for npc in &self.state.npcs {
let d = distance(px, py, npc.x, npc.y);
if d <= INTERACTION_RADIUS_M {
match best_npc {
Some((bd, _)) if d >= bd => {}
_ => best_npc = Some((d, npc.id.clone())),
}
}
}
best_npc.map(|(_, id)| id)
}
}
fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
let dx = ax - bx;
let dy = ay - by;
(dx * dx + dy * dy).sqrt()
}