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 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 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 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
216fn 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 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 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 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}