Skip to main content

flatland_client_lib/
game.rs

1use std::collections::VecDeque;
2
3use flatland_protocol::{
4    BlueprintView, BuildingView, DoorView, EntityId, EntityState, Intent, LifeState, NpcView,
5    Seq, SessionId, Tick,
6};
7
8use crate::session::{PlayConnection, SessionEvent};
9
10const MAX_LOG_LINES: usize = 200;
11const INTERACTION_RADIUS_M: f32 = 1.5;
12const DOOR_INTERACTION_RADIUS_M: f32 = 3.5;
13
14#[derive(Debug, Clone)]
15pub struct GameState {
16    pub session_id: SessionId,
17    pub entity_id: EntityId,
18    pub tick: Tick,
19    pub chunk_rev: u64,
20    pub entities: Vec<EntityState>,
21    pub player: Option<EntityState>,
22    pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
23    pub buildings: Vec<BuildingView>,
24    pub doors: Vec<DoorView>,
25    pub npcs: Vec<NpcView>,
26    pub blueprints: Vec<BlueprintView>,
27    pub world_width_m: f32,
28    pub world_height_m: f32,
29    pub inventory: std::collections::HashMap<String, u32>,
30    pub logs: VecDeque<String>,
31    pub intents_sent: u64,
32    pub ticks_received: u64,
33    pub connected: bool,
34    pub show_stats: bool,
35    pub show_craft_menu: bool,
36    pub craft_menu_index: usize,
37}
38
39impl GameState {
40    pub fn push_log(&mut self, line: impl Into<String>) {
41        self.logs.push_back(line.into());
42        while self.logs.len() > MAX_LOG_LINES {
43            self.logs.pop_front();
44        }
45    }
46
47    pub fn is_alive(&self) -> bool {
48        self.player
49            .as_ref()
50            .and_then(|p| p.vitals)
51            .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
52            .unwrap_or(true)
53    }
54
55    pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
56        self.player.as_ref().and_then(|p| p.vitals)
57    }
58
59    pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
60        blueprint.inputs.iter().all(|input| {
61            self.inventory
62                .get(&input.template_id)
63                .copied()
64                .unwrap_or(0)
65                >= input.quantity
66        }) && blueprint.required_tools.iter().all(|tool| {
67            self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1
68        })
69    }
70
71    /// Short hint for UI when a recipe cannot be started.
72    pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
73        if self.can_craft_blueprint(blueprint) {
74            return None;
75        }
76        let mut missing = Vec::new();
77        for input in &blueprint.inputs {
78            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
79            if have < input.quantity {
80                missing.push(format!("{}×{} (have {have})", input.quantity, input.template_id));
81            }
82        }
83        for tool in &blueprint.required_tools {
84            let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
85            if have < 1 {
86                missing.push(format!("tool: {}", tool.item));
87            }
88        }
89        if missing.is_empty() {
90            None
91        } else {
92            Some(missing.join(", "))
93        }
94    }
95
96    pub fn player_entity(&self) -> Option<&EntityState> {
97        self.player
98            .as_ref()
99            .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
100    }
101
102    pub fn player_position(&self) -> (f32, f32) {
103        self.player_entity()
104            .map(|p| (p.transform.position.x, p.transform.position.y))
105            .unwrap_or((0.0, 0.0))
106    }
107
108    /// Building interior the player is standing in, if any (position-based).
109    pub fn building_interior_at(&self, px: f32, py: f32) -> Option<&BuildingView> {
110        self.buildings
111            .iter()
112            .find(|b| point_in_building_interior(px, py, b))
113    }
114
115    /// Interior instance map to render (ignores stale `inside_building` when outdoors).
116    pub fn effective_inside_building(&self) -> Option<String> {
117        let (px, py) = self.player_position();
118        if let Some(b) = self.building_interior_at(px, py) {
119            return Some(b.id.clone());
120        }
121        if is_instanced_world_coord(px, py, self.world_width_m, self.world_height_m) {
122            if let Some(id) = self
123                .player_entity()
124                .and_then(|p| p.inside_building.clone())
125            {
126                return Some(id);
127            }
128            if self.buildings.len() == 1 {
129                return Some(self.buildings[0].id.clone());
130            }
131        }
132        None
133    }
134
135    pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
136        self.inventory.clear();
137        for stack in stacks {
138            *self.inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
139        }
140    }
141
142    fn apply_snapshot_fields(&mut self, snapshot: &flatland_protocol::Snapshot, entity_id: EntityId) {
143        self.tick = snapshot.tick;
144        self.chunk_rev = snapshot.chunk_rev;
145        self.resource_nodes = snapshot.resource_nodes.clone();
146        self.world_width_m = snapshot.world_width_m;
147        self.world_height_m = snapshot.world_height_m;
148        self.buildings = snapshot.buildings.clone();
149        self.doors = snapshot.doors.clone();
150        self.npcs = snapshot.npcs.clone();
151        self.blueprints = snapshot.blueprints.clone();
152        self.sync_inventory_from_stacks(&snapshot.inventory);
153        self.player = snapshot
154            .entities
155            .iter()
156            .find(|e| e.id == entity_id)
157            .cloned();
158        self.entities = snapshot.entities.clone();
159    }
160
161    fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
162        self.tick = delta.tick;
163        if !delta.buildings.is_empty() {
164            self.buildings = delta.buildings.clone();
165        }
166        if !delta.blueprints.is_empty() {
167            self.blueprints = delta.blueprints.clone();
168        }
169        self.sync_inventory_from_stacks(&delta.inventory);
170
171        if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
172            self.player = Some(updated.clone());
173        }
174        self.entities = delta.entities.clone();
175        if self.player.is_none() {
176            self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
177        }
178
179        if let Some(player) = self.player.as_ref() {
180            let (px, py) = (player.transform.position.x, player.transform.position.y);
181            let stale_inside = player.inside_building.is_some()
182                && self.building_interior_at(px, py).is_none()
183                && !is_instanced_world_coord(px, py, self.world_width_m, self.world_height_m);
184            if stale_inside {
185                self.player.as_mut().unwrap().inside_building = None;
186            }
187        }
188
189        let outdoors = self.effective_inside_building().is_none();
190        if outdoors {
191            self.resource_nodes = delta.resource_nodes.clone();
192        } else if !delta.resource_nodes.is_empty() {
193            self.resource_nodes = delta.resource_nodes.clone();
194        }
195        if outdoors {
196            self.doors = delta.doors.clone();
197            self.npcs = delta.npcs.clone();
198        } else {
199            if !delta.doors.is_empty() {
200                self.doors = delta.doors.clone();
201            }
202            if !delta.npcs.is_empty() {
203                self.npcs = delta.npcs.clone();
204            }
205        }
206    }
207}
208
209fn point_in_building_interior(px: f32, py: f32, building: &BuildingView) -> bool {
210    px >= building.interior_origin_x
211        && px <= building.interior_origin_x + building.width_m
212        && py >= building.interior_origin_y
213        && py <= building.interior_origin_y + building.depth_m
214}
215
216/// Coordinates in a separate interior instance (outside the outdoor segment bounds).
217fn is_instanced_world_coord(px: f32, py: f32, world_w: f32, world_h: f32) -> bool {
218    if world_w > 0.0 && world_h > 0.0 {
219        px < 0.0 || py < 0.0 || px > world_w || py > world_h
220    } else {
221        px > 256.0 || py > 256.0
222    }
223}
224
225pub struct GameClient<S: PlayConnection> {
226    session: S,
227    seq: Seq,
228    pub state: GameState,
229}
230
231impl<S: PlayConnection> GameClient<S> {
232    pub fn new(session: S) -> Self {
233        let session_id = session.session_id();
234        let entity_id = session.entity_id();
235        Self {
236            session,
237            seq: 0,
238            state: GameState {
239                session_id,
240                entity_id,
241                tick: 0,
242                chunk_rev: 0,
243                entities: Vec::new(),
244                player: None,
245                resource_nodes: Vec::new(),
246                buildings: Vec::new(),
247                doors: Vec::new(),
248                npcs: Vec::new(),
249                blueprints: Vec::new(),
250                world_width_m: 0.0,
251                world_height_m: 0.0,
252                inventory: std::collections::HashMap::new(),
253                logs: VecDeque::new(),
254                intents_sent: 0,
255                ticks_received: 0,
256                connected: false,
257                show_stats: false,
258                show_craft_menu: false,
259                craft_menu_index: 0,
260            },
261        }
262    }
263
264    pub fn entity_id(&self) -> EntityId {
265        self.state.entity_id
266    }
267
268    pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
269        if self.state.connected {
270            return Ok(());
271        }
272
273        loop {
274            match self.session.next_event().await {
275                Some(SessionEvent::Welcome {
276                    session_id,
277                    entity_id,
278                    snapshot,
279                }) => {
280                    self.state.session_id = session_id;
281                    self.state.entity_id = entity_id;
282                    self.state.apply_snapshot_fields(&snapshot, entity_id);
283                    self.state.connected = true;
284                    self.state.push_log(format!(
285                        "Connected — session {session_id}, entity {entity_id}"
286                    ));
287                    return Ok(());
288                }
289                Some(SessionEvent::Disconnected { .. }) => {
290                    anyhow::bail!("disconnected before welcome");
291                }
292                Some(_) => continue,
293                None => anyhow::bail!("session closed before welcome"),
294            }
295        }
296    }
297
298    /// Drain all pending server events (non-blocking).
299    pub fn drain_events(&mut self) {
300        while let Some(event) = self.session.try_next_event() {
301            if self.handle_event_sync(event).is_err() {
302                break;
303            }
304        }
305    }
306
307    /// Wait for the next server event.
308    pub async fn next_event(&mut self) -> Option<SessionEvent> {
309        self.session.next_event().await
310    }
311
312    pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
313        self.handle_event_sync(event)
314    }
315
316    fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
317        match event {
318            SessionEvent::Welcome {
319                session_id,
320                entity_id,
321                snapshot,
322            } => {
323                self.state.session_id = session_id;
324                self.state.entity_id = entity_id;
325                self.state.apply_snapshot_fields(&snapshot, entity_id);
326                self.state.connected = true;
327            }
328            SessionEvent::Tick(delta) => {
329                self.state.apply_tick_fields(&delta, self.state.entity_id);
330                self.state.ticks_received += 1;
331            }
332            SessionEvent::IntentAck { .. } => {}
333            SessionEvent::Chat(msg) => {
334                let label = match msg.channel {
335                    flatland_protocol::ChatChannel::Nearby => "nearby",
336                    flatland_protocol::ChatChannel::WhisperStone => "whisper",
337                };
338                self.state.push_log(format!(
339                    "[{label}] {}: {}",
340                    msg.from_name, msg.text
341                ));
342            }
343            SessionEvent::HarvestResult(result) => {
344                *self
345                    .state
346                    .inventory
347                    .entry(result.item_template.clone())
348                    .or_insert(0) += result.quantity;
349                self.state.push_log(format!(
350                    "Harvested {} x{}",
351                    result.item_template, result.quantity
352                ));
353            }
354            SessionEvent::CraftResult(result) => {
355                if let Some(output) = result.outputs.first() {
356                    self.state.push_log(format!(
357                        "Crafted {} x{}",
358                        output.template_id, output.quantity
359                    ));
360                } else {
361                    self.state.push_log(format!("Craft finished: {}", result.blueprint_id));
362                }
363            }
364            SessionEvent::Death(notice) => {
365                self.state.push_log(notice.message.clone());
366                self.state.push_log(format!(
367                    "Respawned at ({:.1}, {:.1})",
368                    notice.respawn_x, notice.respawn_y
369                ));
370            }
371            SessionEvent::Interaction(notice) => {
372                self.state.push_log(notice.message.clone());
373            }
374            SessionEvent::Disconnected { reason } => {
375                self.state.connected = false;
376                if let Some(r) = reason.filter(|s| !s.is_empty()) {
377                    self.state.push_log(format!("Disconnected: {r}"));
378                } else {
379                    self.state.push_log("Disconnected from server");
380                }
381            }
382        }
383        Ok(())
384    }
385
386    pub fn is_connected(&self) -> bool {
387        self.state.connected
388    }
389
390    pub fn toggle_stats(&mut self) {
391        self.state.show_stats = !self.state.show_stats;
392        if self.state.show_stats {
393            self.state.show_craft_menu = false;
394        }
395    }
396
397    pub fn open_craft_menu(&mut self) {
398        self.state.show_craft_menu = true;
399        self.state.show_stats = false;
400        if self.state.blueprints.is_empty() {
401            self.state.craft_menu_index = 0;
402            return;
403        }
404        self.state.craft_menu_index = self
405            .state
406            .craft_menu_index
407            .min(self.state.blueprints.len() - 1);
408        if let Some(idx) = self
409            .state
410            .blueprints
411            .iter()
412            .position(|bp| self.state.can_craft_blueprint(bp))
413        {
414            self.state.craft_menu_index = idx;
415        }
416    }
417
418    pub fn close_craft_menu(&mut self) {
419        self.state.show_craft_menu = false;
420    }
421
422    pub fn craft_menu_move(&mut self, delta: i32) {
423        let n = self.state.blueprints.len();
424        if n == 0 {
425            return;
426        }
427        let idx = self.state.craft_menu_index as i32;
428        let next = (idx + delta).rem_euclid(n as i32);
429        self.state.craft_menu_index = next as usize;
430    }
431
432    pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
433        let Some(blueprint) = self
434            .state
435            .blueprints
436            .get(self.state.craft_menu_index)
437            .cloned()
438        else {
439            anyhow::bail!("no blueprints known");
440        };
441        if !self.state.can_craft_blueprint(&blueprint) {
442            let hint = self
443                .state
444                .craft_missing_hint(&blueprint)
445                .unwrap_or_else(|| "missing materials".into());
446            anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
447        }
448        self.craft(&blueprint.id).await?;
449        self.state.show_craft_menu = false;
450        Ok(())
451    }
452
453    pub async fn move_by(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
454        if !self.state.is_alive() {
455            anyhow::bail!("you are dead");
456        }
457        self.seq += 1;
458        self.session
459            .submit_intent(Intent::Move {
460                entity_id: self.state.entity_id,
461                forward,
462                strafe,
463                seq: self.seq,
464            })
465            .await?;
466        self.state.intents_sent += 1;
467        Ok(())
468    }
469
470    pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
471        if !self.state.connected {
472            anyhow::bail!("not connected");
473        }
474        if !self.state.is_alive() {
475            anyhow::bail!("you are dead");
476        }
477        let (px, py) = self
478            .state
479            .player
480            .as_ref()
481            .map(|p| (p.transform.position.x, p.transform.position.y))
482            .unwrap_or((0.0, 0.0));
483
484        let node_id = self
485            .state
486            .resource_nodes
487            .iter()
488            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
489            .min_by(|a, b| {
490                let da = distance(px, py, a.x, a.y);
491                let db = distance(px, py, b.x, b.y);
492                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
493            })
494            .map(|n| n.id.clone())
495            .ok_or_else(|| anyhow::anyhow!("no harvestable nodes in view"))?;
496
497        self.seq += 1;
498        self.session
499            .submit_intent(Intent::Harvest {
500                entity_id: self.state.entity_id,
501                node_id,
502                seq: self.seq,
503            })
504            .await?;
505        self.state.intents_sent += 1;
506        Ok(())
507    }
508
509    pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
510        if !self.state.is_alive() {
511            anyhow::bail!("you are dead");
512        }
513        let blueprint_id = self
514            .state
515            .blueprints
516            .iter()
517            .find(|bp| self.state.can_craft_blueprint(bp))
518            .map(|bp| bp.id.clone())
519            .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
520        self.craft(&blueprint_id).await
521    }
522
523    pub async fn craft(&mut self, blueprint_id: &str) -> anyhow::Result<()> {
524        if !self.state.is_alive() {
525            anyhow::bail!("you are dead");
526        }
527        self.seq += 1;
528        self.session
529            .submit_intent(Intent::Craft {
530                entity_id: self.state.entity_id,
531                blueprint_id: blueprint_id.to_string(),
532                seq: self.seq,
533            })
534            .await?;
535        self.state.intents_sent += 1;
536        let label = self
537            .state
538            .blueprints
539            .iter()
540            .find(|b| b.id == blueprint_id)
541            .map(|b| b.label.as_str())
542            .unwrap_or(blueprint_id);
543        self.state.push_log(format!("Crafting {label}…"));
544        Ok(())
545    }
546
547    pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
548        if !self.state.is_alive() {
549            anyhow::bail!("you are dead");
550        }
551        let target_id = self
552            .nearest_interact_target()
553            .ok_or_else(|| anyhow::anyhow!("nothing to interact with nearby"))?;
554        self.seq += 1;
555        self.session
556            .submit_intent(Intent::Interact {
557                entity_id: self.state.entity_id,
558                target_id: target_id.clone(),
559                seq: self.seq,
560            })
561            .await?;
562        self.state.intents_sent += 1;
563        Ok(())
564    }
565
566    pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
567        self.seq += 1;
568        self.session
569            .submit_intent(Intent::TestDamage {
570                entity_id: self.state.entity_id,
571                amount,
572                seq: self.seq,
573            })
574            .await?;
575        self.state.intents_sent += 1;
576        Ok(())
577    }
578
579    pub async fn say(&mut self, channel: flatland_protocol::ChatChannel, text: &str) -> anyhow::Result<()> {
580        self.seq += 1;
581        self.session
582            .submit_intent(Intent::Say {
583                entity_id: self.state.entity_id,
584                channel,
585                text: text.to_string(),
586                seq: self.seq,
587            })
588            .await?;
589        self.state.intents_sent += 1;
590        Ok(())
591    }
592
593    pub async fn stop(&mut self) -> anyhow::Result<()> {
594        self.seq += 1;
595        self.session
596            .submit_intent(Intent::Stop {
597                entity_id: self.state.entity_id,
598                seq: self.seq,
599            })
600            .await?;
601        self.state.intents_sent += 1;
602        Ok(())
603    }
604
605    pub fn disconnect(&self) {
606        self.session.disconnect();
607    }
608
609    fn nearest_interact_target(&self) -> Option<String> {
610        let (px, py) = self.state.player_position();
611        let inside = self.state.effective_inside_building();
612
613        let door_in_range = |door: &DoorView| -> Option<(f32, String)> {
614            if inside.is_none() && door.is_exit {
615                return None;
616            }
617            if let Some(ref bid) = inside {
618                if door.building_id != *bid {
619                    return None;
620                }
621            }
622            let d = distance(px, py, door.x, door.y);
623            if d <= DOOR_INTERACTION_RADIUS_M {
624                Some((d, door.id.clone()))
625            } else {
626                None
627            }
628        };
629
630        // Doors before NPCs — exit doors first when inside (so spawn-on-NPC doesn't block leaving).
631        if inside.is_some() {
632            let mut best_exit: Option<(f32, String)> = None;
633            for door in &self.state.doors {
634                if !door.is_exit {
635                    continue;
636                }
637                if let Some((d, id)) = door_in_range(door) {
638                    match best_exit {
639                        Some((bd, _)) if d >= bd => {}
640                        _ => best_exit = Some((d, id)),
641                    }
642                }
643            }
644            if let Some((_, id)) = best_exit {
645                return Some(id);
646            }
647        }
648
649        let mut best_door: Option<(f32, String)> = None;
650        for door in &self.state.doors {
651            if door.is_exit {
652                continue;
653            }
654            if let Some((d, id)) = door_in_range(door) {
655                match best_door {
656                    Some((bd, _)) if d >= bd => {}
657                    _ => best_door = Some((d, id)),
658                }
659            }
660        }
661        if let Some((_, id)) = best_door {
662            return Some(id);
663        }
664
665        let mut best_npc: Option<(f32, String)> = None;
666        for npc in &self.state.npcs {
667            let d = distance(px, py, npc.x, npc.y);
668            if d <= INTERACTION_RADIUS_M {
669                match best_npc {
670                    Some((bd, _)) if d >= bd => {}
671                    _ => best_npc = Some((d, npc.id.clone())),
672                }
673            }
674        }
675        best_npc.map(|(_, id)| id)
676    }
677}
678
679fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
680    let dx = ax - bx;
681    let dy = ay - by;
682    (dx * dx + dy * dy).sqrt()
683}