feat: add persistent-world station game
Bevy ECS workspace with three crates:
- game_core: rsn data definitions, components, Tiled map loader, protocol
- game_server: authoritative simulation, Rune scripting, persistence,
dedicated server binary
- game_client: rendering, UI and input; embeds the server for
singleplayer and hosting
Items, recipes, buildings and skills are defined in assets/data, behavior
in Rune scripts under assets/scripts, and maps are made in Tiled.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-18 17:44:13 +00:00
|
|
|
//! Login/logout and the mapping between network clients and characters.
|
|
|
|
|
|
|
|
|
|
use std::collections::{BTreeMap, HashMap};
|
|
|
|
|
|
|
|
|
|
use bevy::prelude::*;
|
|
|
|
|
use bevy_replicon::prelude::*;
|
|
|
|
|
use game_core::{
|
|
|
|
|
GameData,
|
|
|
|
|
components::*,
|
|
|
|
|
protocol::{GameLog, Login},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
use crate::{
|
|
|
|
|
movement::MoveIntent,
|
|
|
|
|
persistence::SavedCharacter,
|
|
|
|
|
world::{SpawnPoint, WorldLayout, find_free_tile},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// Which client controls a character. Server-only.
|
|
|
|
|
#[derive(Component, Clone, Copy, Debug)]
|
|
|
|
|
pub struct Controller(pub ClientId);
|
|
|
|
|
|
|
|
|
|
/// Online characters by client.
|
|
|
|
|
#[derive(Resource, Default)]
|
|
|
|
|
pub struct Players {
|
|
|
|
|
pub by_client: HashMap<ClientId, Entity>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Characters that are currently offline, by name. Persisted with the world.
|
|
|
|
|
#[derive(Resource, Default)]
|
|
|
|
|
pub struct CharacterStore(pub BTreeMap<String, SavedCharacter>);
|
|
|
|
|
|
|
|
|
|
pub const MAX_NAME_LEN: usize = 24;
|
|
|
|
|
|
|
|
|
|
pub fn tell(commands: &mut Commands, client: ClientId, text: impl Into<String>) {
|
|
|
|
|
commands.server_trigger(ToClients {
|
|
|
|
|
targets: SendTargets::Single(client),
|
|
|
|
|
message: GameLog { text: text.into() },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn valid_name(name: &str) -> bool {
|
|
|
|
|
let len = name.chars().count();
|
|
|
|
|
(1..=MAX_NAME_LEN).contains(&len)
|
|
|
|
|
&& name.trim() == name
|
|
|
|
|
&& name
|
|
|
|
|
.chars()
|
|
|
|
|
.all(|c| c.is_alphanumeric() || c == ' ' || c == '_' || c == '-')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
|
|
|
pub fn on_login(
|
|
|
|
|
login: On<FromClient<Login>>,
|
|
|
|
|
mut commands: Commands,
|
|
|
|
|
mut players: ResMut<Players>,
|
|
|
|
|
mut store: ResMut<CharacterStore>,
|
|
|
|
|
mut disconnects: MessageWriter<DisconnectRequest>,
|
|
|
|
|
data: Res<GameData>,
|
|
|
|
|
spawn: Res<SpawnPoint>,
|
|
|
|
|
layout: Res<WorldLayout>,
|
|
|
|
|
characters: Query<&Character>,
|
|
|
|
|
buildings: Query<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>,
|
|
|
|
|
) {
|
|
|
|
|
let client = login.client_id;
|
|
|
|
|
let name = login.message.name.clone();
|
|
|
|
|
if players.by_client.contains_key(&client) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let reject = |commands: &mut Commands,
|
|
|
|
|
disconnects: &mut MessageWriter<DisconnectRequest>,
|
|
|
|
|
reason: &str| {
|
|
|
|
|
tell(commands, client, reason);
|
|
|
|
|
if let ClientId::Client(entity) = client {
|
|
|
|
|
disconnects.write(DisconnectRequest { client: entity });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if !valid_name(&name) {
|
|
|
|
|
reject(&mut commands, &mut disconnects, "Invalid character name.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if characters.iter().any(|c| c.name == name) {
|
|
|
|
|
reject(
|
|
|
|
|
&mut commands,
|
|
|
|
|
&mut disconnects,
|
|
|
|
|
"That character is already online.",
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let (saved, returning) = match store.0.remove(&name) {
|
|
|
|
|
Some(saved) => (saved, true),
|
2026-10-18 18:47:43 +00:00
|
|
|
None => (new_character(&data, &name, spawn.0), false),
|
feat: add persistent-world station game
Bevy ECS workspace with three crates:
- game_core: rsn data definitions, components, Tiled map loader, protocol
- game_server: authoritative simulation, Rune scripting, persistence,
dedicated server binary
- game_client: rendering, UI and input; embeds the server for
singleplayer and hosting
Items, recipes, buildings and skills are defined in assets/data, behavior
in Rune scripts under assets/scripts, and maps are made in Tiled.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-18 17:44:13 +00:00
|
|
|
};
|
|
|
|
|
let pos = find_free_tile(saved.pos, &layout.0, &buildings);
|
|
|
|
|
let entity = commands
|
|
|
|
|
.spawn((
|
|
|
|
|
Replicated,
|
|
|
|
|
Character { name: name.clone() },
|
|
|
|
|
pos,
|
|
|
|
|
Facing(saved.facing),
|
|
|
|
|
saved.vitals,
|
|
|
|
|
saved.inventory,
|
|
|
|
|
saved.skills,
|
|
|
|
|
Controller(client),
|
|
|
|
|
MoveIntent::default(),
|
|
|
|
|
))
|
|
|
|
|
.id();
|
|
|
|
|
players.by_client.insert(client, entity);
|
|
|
|
|
info!("{name} logged in ({client:?})");
|
|
|
|
|
let greeting = if returning {
|
|
|
|
|
format!("Welcome back, {name}.")
|
|
|
|
|
} else {
|
|
|
|
|
format!("Welcome to the station, {name}. Press H for help.")
|
|
|
|
|
};
|
|
|
|
|
tell(&mut commands, client, greeting);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 18:47:43 +00:00
|
|
|
fn new_character(data: &GameData, name: &str, spawn: GridPos) -> SavedCharacter {
|
feat: add persistent-world station game
Bevy ECS workspace with three crates:
- game_core: rsn data definitions, components, Tiled map loader, protocol
- game_server: authoritative simulation, Rune scripting, persistence,
dedicated server binary
- game_client: rendering, UI and input; embeds the server for
singleplayer and hosting
Items, recipes, buildings and skills are defined in assets/data, behavior
in Rune scripts under assets/scripts, and maps are made in Tiled.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-18 17:44:13 +00:00
|
|
|
let mut inventory = Inventory::default();
|
|
|
|
|
if data.items.contains_key("apple") {
|
|
|
|
|
inventory.add("apple", 2, data);
|
|
|
|
|
}
|
|
|
|
|
SavedCharacter {
|
2026-10-18 18:47:43 +00:00
|
|
|
name: name.to_owned(),
|
feat: add persistent-world station game
Bevy ECS workspace with three crates:
- game_core: rsn data definitions, components, Tiled map loader, protocol
- game_server: authoritative simulation, Rune scripting, persistence,
dedicated server binary
- game_client: rendering, UI and input; embeds the server for
singleplayer and hosting
Items, recipes, buildings and skills are defined in assets/data, behavior
in Rune scripts under assets/scripts, and maps are made in Tiled.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-18 17:44:13 +00:00
|
|
|
pos: spawn,
|
|
|
|
|
facing: Default::default(),
|
|
|
|
|
vitals: Vitals::default(),
|
|
|
|
|
inventory,
|
|
|
|
|
skills: Skills::default(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Stores the character of a disconnecting client and removes it from the world.
|
|
|
|
|
pub fn on_disconnect(
|
|
|
|
|
remove: On<Remove, ConnectedClient>,
|
|
|
|
|
mut commands: Commands,
|
|
|
|
|
mut players: ResMut<Players>,
|
|
|
|
|
mut store: ResMut<CharacterStore>,
|
|
|
|
|
characters: Query<(&Character, &GridPos, &Facing, &Vitals, &Inventory, &Skills)>,
|
|
|
|
|
) {
|
|
|
|
|
let Some(entity) = players.by_client.remove(&ClientId::Client(remove.entity)) else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
if let Ok((c, pos, facing, vitals, inventory, skills)) = characters.get(entity) {
|
|
|
|
|
info!("{} logged out", c.name);
|
|
|
|
|
store.0.insert(
|
|
|
|
|
c.name.clone(),
|
|
|
|
|
SavedCharacter {
|
2026-10-18 18:47:43 +00:00
|
|
|
name: c.name.clone(),
|
feat: add persistent-world station game
Bevy ECS workspace with three crates:
- game_core: rsn data definitions, components, Tiled map loader, protocol
- game_server: authoritative simulation, Rune scripting, persistence,
dedicated server binary
- game_client: rendering, UI and input; embeds the server for
singleplayer and hosting
Items, recipes, buildings and skills are defined in assets/data, behavior
in Rune scripts under assets/scripts, and maps are made in Tiled.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-18 17:44:13 +00:00
|
|
|
pos: *pos,
|
|
|
|
|
facing: facing.0,
|
|
|
|
|
vitals: vitals.clone(),
|
|
|
|
|
inventory: inventory.clone(),
|
|
|
|
|
skills: skills.clone(),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
commands.entity(entity).despawn();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::valid_name;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn names() {
|
|
|
|
|
assert!(valid_name("Jane Doe"));
|
|
|
|
|
assert!(valid_name("x_1-2"));
|
|
|
|
|
assert!(!valid_name(""));
|
|
|
|
|
assert!(!valid_name(" padded"));
|
|
|
|
|
assert!(!valid_name("semi;colon"));
|
|
|
|
|
assert!(!valid_name(&"a".repeat(25)));
|
|
|
|
|
}
|
|
|
|
|
}
|