MudEngine Part 11: Peer-to-Peer (Hybrid)
Extract game logic into a module
The game logic — GameState, RoomData, can_move(), update_quest(),
check_quest_completion(), save_game_state() — currently lives inside
#[cfg(feature = "server")] mod srv { .. }. Since we are removing the server
feature entirely, this code needs to be available unconditionally to the host
binary.
Create a new file src/game_engine.rs and move all game types and functions
into it. The host binary will own an instance of GameEngine wrapped in a
Mutex, just like the server did, but now it runs in-process alongside the
Dioxus UI and the iroh endpoint.
use std::collections::HashMap; use std::sync::Mutex; use serde::{Deserialize, Serialize}; // ── Shared types (moved from main.rs) ── #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct PlayerInfo { pub id: String, pub name: String, pub room: usize, pub items: Vec<String>, } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct RoomInfo { pub id: usize, pub name: String, pub description: String, pub items: Vec<String>, } #[derive(Serialize, Deserialize, Debug, Clone)] pub enum ClientMessage { Join { name: String }, Move { direction: String }, Say { message: String }, Take { item: String }, Drop { item: String }, } #[derive(Serialize, Deserialize, Debug, Clone)] pub enum ServerMessage { State { players: Vec<PlayerInfo>, your_id: String, rooms: Vec<RoomInfo>, quest_active: bool, quest_completed: bool, }, PlayerJoined(PlayerInfo), PlayerMoved(PlayerInfo), PlayerLeft { id: String }, Chat { from: String, message: String, room: usize }, ItemTaken { player_id: String, player_name: String, item: String }, ItemDropped { player_id: String, player_name: String, item: String, room: usize }, QuestEvent { active: bool, message: String }, QuestCompleted { message: String }, } // ── TOML types ── #[derive(Deserialize)] struct TomlExit { direction: String, destination: usize, } #[derive(Deserialize)] struct TomlRoom { id: usize, name: String, description: String, exits: Vec<TomlExit>, items: Vec<String>, } #[derive(Deserialize)] struct TomlPlayer { id: String, name: String, room: usize, items: Vec<String>, } #[derive(Deserialize)] struct TomlGame { rooms: Vec<TomlRoom>, players: Vec<TomlPlayer>, quest: Option<TomlQuest>, } #[derive(Deserialize, Clone)] struct TomlQuest { name: String, retrieval_item: String, completion_room: usize, guardians: Vec<TomlQuestGuardian>, reward: TomlQuestReward, } #[derive(Deserialize, Clone)] struct TomlQuestGuardian { name: String, room_id: usize, label: String, } #[derive(Deserialize, Clone)] struct TomlQuestReward { source_room_id: usize, target_room_id: usize, direction: String, target_name: String, target_description: String, } // ── Runtime types ── #[derive(Clone)] pub struct RoomData { pub id: usize, pub name: String, pub description: String, pub exits: Vec<(String, usize)>, pub items: Vec<String>, } #[derive(Clone)] struct QuestStatus { config: TomlQuest, previously_active: bool, completed: bool, } #[derive(Clone)] pub struct GameState { pub rooms: Vec<RoomData>, pub players: HashMap<String, PlayerInfo>, quest: Option<QuestStatus>, } // ── GameEngine ── pub struct GameEngine { state: Mutex<GameState>, } impl GameEngine { pub fn load(path: &str) -> Self { let content = std::fs::read_to_string(path) .unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e)); let toml_game: TomlGame = toml::from_str(&content) .unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e)); let rooms = toml_game.rooms.iter().map(|r| RoomData { id: r.id, name: r.name.clone(), description: r.description.clone(), exits: r.exits.iter().map(|e| (e.direction.clone(), e.destination)).collect(), items: r.items.clone(), }).collect(); let players = toml_game.players.iter().map(|p| { let pi = PlayerInfo { id: p.id.clone(), name: p.name.clone(), room: p.room, items: p.items.clone(), }; (p.id.clone(), pi) }).collect(); let quest = toml_game.quest.map(|config| QuestStatus { config, previously_active: false, completed: false, }); GameEngine { state: Mutex::new(GameState { rooms, players, quest }) } } pub fn save(&self, path: &str) { // Serialization logic omitted for brevity — identical to Part 9 } pub fn add_player(&self, id: &str, name: &str, room: usize) -> Vec<RoomInfo> { let mut state = self.state.lock().unwrap(); state.players.insert(id.into(), PlayerInfo { id: id.into(), name: name.into(), room, items: vec![], }); state.rooms.iter().map(|r| RoomInfo { id: r.id, name: r.name.clone(), description: r.description.clone(), items: r.items.clone(), }).collect() } pub fn remove_player(&self, id: &str) { let mut state = self.state.lock().unwrap(); state.players.remove(id); } pub fn handle_move(&self, id: &str, direction: &str) -> (Option<(bool, String)>, Option<String>) { let mut state = self.state.lock().unwrap(); let dir = match direction { "n" => "north", "s" => "south", "e" => "east", "w" => "west", d => d, }; let moved = { let player = match state.players.get_mut(id) { Some(p) => p, None => return (None, None), }; let current = player.room; // Check normal exits let next = state.rooms.iter() .find(|r| r.id == current) .and_then(|r| r.exits.iter().find(|(d, _)| d == dir)) .map(|(_, dest)| *dest); // Check quest portal (down from room 4 when active) let next = next.or_else(|| { if dir == "down" && current == 4 { let active = state.quest.as_ref() .map(|q| q.previously_active).unwrap_or(false); if active { Some(9) } else { None } } else { None } }); if let Some(room) = next { player.room = room; true } else { false } }; if moved { let quest_event = self.update_quest_inner(&mut state); let completion = self.check_completion_inner(&mut state); (quest_event, completion) } else { (None, None) } } pub fn handle_take(&self, id: &str, item: &str) -> Option<String> { let mut state = self.state.lock().unwrap(); let player = state.players.get_mut(id)?; let room_id = player.room; let room = state.rooms.iter_mut().find(|r| r.id == room_id)?; let pos = room.items.iter().position(|i| i == item)?; room.items.remove(pos); player.items.push(item.into()); Some(self.check_completion_inner(&mut state).flatten()?) } // Quest logic — same as Part 9, adapted for GameEngine fn update_quest_inner(&self, state: &mut GameState) -> Option<(bool, String)> { let quest = state.quest.as_mut()?; if quest.completed { return None; } let all_occupied = quest.config.guardians.iter() .all(|g| state.players.values().any(|p| p.room == g.room_id)); let now_active = all_occupied && state.players.len() >= 2; if now_active == quest.previously_active { return None; } quest.previously_active = now_active; if now_active { let reward = &quest.config.reward; if let Some(room) = state.rooms.iter_mut().find(|r| r.id == reward.source_room_id) { if !room.exits.iter().any(|(d, _)| d == &reward.direction) { room.exits.push((reward.direction.clone(), reward.target_room_id)); } } if let Some(room) = state.rooms.iter_mut().find(|r| r.id == reward.target_room_id) { if !room.exits.iter().any(|(d, _)| d == "up") { room.exits.push(("up".into(), reward.source_room_id)); } } Some((true, format!( "☀️🌙 The Twin Guardians are united! A shimmering portal opens!" ))) } else { let reward = &quest.config.reward; if let Some(room) = state.rooms.iter_mut().find(|r| r.id == reward.source_room_id) { room.exits.retain(|(d, _)| d != &reward.direction); } let victims: Vec<String> = state.players.iter() .filter(|(_, p)| p.room == reward.target_room_id) .map(|(id, _)| id.clone()).collect(); for vid in &victims { if let Some(p) = state.players.get_mut(vid) { p.room = reward.source_room_id; } } Some((false, format!("🌙☀️ The portal seals shut!"))) } } fn check_completion_inner(&self, state: &mut GameState) -> Option<Option<String>> { let quest = state.quest.as_mut()?; if quest.completed { return Some(None); } let item = &quest.config.retrieval_item; let room_id = quest.config.completion_room; let on_floor = state.rooms.iter().any(|r| r.id == room_id && r.items.contains(item)); let in_inv = state.players.values().any(|p| p.room == room_id && p.items.contains(item)); if on_floor || in_inv { quest.completed = true; Some(Some(format!( "👑 King Aldric beams with joy! The Star Fragment is returned!" ))) } else { Some(None) } } }
The GameEngine struct wraps Mutex<GameState> and exposes safe methods:
add_player()/remove_player()— connection lifecyclehandle_move()— movement with quest integrationhandle_take()— item pickup with quest completion check
Internally it calls the same update_quest_inner() and check_completion_inner()
logic from Part 9, but no longer behind #[cfg(feature = "server")].
No server functions (#[get], #[post]), no Axum, no WebSocket upgrade handlers.
The game logic is a plain Rust struct.