Update source files
86
crates/borders-core/Cargo.toml
Normal file
@@ -0,0 +1,86 @@
|
||||
[package]
|
||||
name = "borders-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[package.metadata.cargo-machete]
|
||||
ignored = ["serde_bytes", "chrono"]
|
||||
|
||||
[features]
|
||||
default = ["ui", "bevy_debug"]
|
||||
bevy_debug = ["bevy_ecs/detailed_trace"]
|
||||
ui = []
|
||||
|
||||
[dependencies]
|
||||
bevy_ecs = { version = "0.17", default-features = false, features = ["std"] }
|
||||
flume = "0.11"
|
||||
futures = "0.3"
|
||||
futures-lite = "2.6.1"
|
||||
glam = { version = "0.30", features = ["serde", "rkyv"] }
|
||||
rkyv = { version = "0.8", features = ["hashbrown-0_15"] }
|
||||
hex = "0.4"
|
||||
hmac = "0.12"
|
||||
image = "0.25"
|
||||
once_cell = "1.20"
|
||||
rand = "0.9"
|
||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||
slotmap = "1.0"
|
||||
serde_bytes = "0.11"
|
||||
serde_json = "1.0"
|
||||
sha2 = "0.10"
|
||||
tracing = "0.1"
|
||||
web-transport = "0.9"
|
||||
|
||||
# Target-specific dependencies to keep WASM builds compatible
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tokio = { version = "1", features = [
|
||||
"rt-multi-thread",
|
||||
"macros",
|
||||
"time",
|
||||
"io-util",
|
||||
"sync",
|
||||
] }
|
||||
reqwest = { version = "0.12", default-features = false, features = [
|
||||
"json",
|
||||
"rustls-tls",
|
||||
"brotli",
|
||||
"gzip",
|
||||
"deflate",
|
||||
"zstd",
|
||||
] }
|
||||
hickory-resolver = { version = "0.25", features = [
|
||||
"tls-ring",
|
||||
"https-ring",
|
||||
"quic-ring",
|
||||
"h3-ring",
|
||||
"webpki-roots",
|
||||
] }
|
||||
uuid = { version = "1.11", features = ["v4", "serde"] }
|
||||
machineid-rs = "1.2"
|
||||
directories = "5.0"
|
||||
ring = "0.17.14"
|
||||
pem = "3.0.5"
|
||||
sysinfo = "0.33"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.52"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
tokio = { version = "1", features = ["rt", "macros", "time", "io-util"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json"] }
|
||||
uuid = { version = "1.11", features = ["v4", "serde", "js"] }
|
||||
js-sys = "0.3"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
gloo-timers = { version = "0.3", features = ["futures"] }
|
||||
web-sys = { version = "0.3", features = [
|
||||
"BroadcastChannel",
|
||||
"MessageEvent",
|
||||
"Navigator",
|
||||
"Window",
|
||||
] }
|
||||
web-time = "1.1"
|
||||
|
||||
[build-dependencies]
|
||||
chrono = "0.4"
|
||||
76
crates/borders-core/assets/maps/World.json
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"tiles": [
|
||||
{
|
||||
"color": "#000000",
|
||||
"name": "Water",
|
||||
"colorBase": "water",
|
||||
"colorVariant": 4,
|
||||
"conquerable": false,
|
||||
"navigable": true
|
||||
},
|
||||
{
|
||||
"color": "#222222",
|
||||
"name": "Water",
|
||||
"colorBase": "water",
|
||||
"colorVariant": 6,
|
||||
"conquerable": false,
|
||||
"navigable": true
|
||||
},
|
||||
{
|
||||
"color": "#555555",
|
||||
"name": "Water",
|
||||
"colorBase": "water",
|
||||
"colorVariant": 12,
|
||||
"conquerable": false,
|
||||
"navigable": true
|
||||
},
|
||||
{
|
||||
"color": "#777777",
|
||||
"name": "Water",
|
||||
"colorBase": "water",
|
||||
"colorVariant": 14,
|
||||
"conquerable": false,
|
||||
"navigable": true
|
||||
},
|
||||
{
|
||||
"color": "#999999",
|
||||
"name": "Land",
|
||||
"colorBase": "mountain",
|
||||
"colorVariant": 5,
|
||||
"conquerable": true,
|
||||
"navigable": false,
|
||||
"expansionCost": 80,
|
||||
"expansionTime": 80
|
||||
},
|
||||
{
|
||||
"color": "#BBBBBB",
|
||||
"name": "Land",
|
||||
"colorBase": "mountain",
|
||||
"colorVariant": 9,
|
||||
"conquerable": true,
|
||||
"navigable": false,
|
||||
"expansionCost": 70,
|
||||
"expansionTime": 70
|
||||
},
|
||||
{
|
||||
"color": "#DDDDDD",
|
||||
"name": "Land",
|
||||
"colorBase": "grass",
|
||||
"colorVariant": 9,
|
||||
"conquerable": true,
|
||||
"navigable": false,
|
||||
"expansionCost": 60,
|
||||
"expansionTime": 60
|
||||
},
|
||||
{
|
||||
"color": "#FFFFFF",
|
||||
"name": "Land",
|
||||
"colorBase": "grass",
|
||||
"colorVariant": 6,
|
||||
"conquerable": true,
|
||||
"navigable": false,
|
||||
"expansionCost": 50,
|
||||
"expansionTime": 50
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
crates/borders-core/assets/maps/World.png
Normal file
|
After Width: | Height: | Size: 185 KiB |
74
crates/borders-core/build.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
// Get the workspace root (two levels up from borders-core)
|
||||
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
|
||||
let workspace_root = manifest_dir.parent().unwrap().parent().unwrap();
|
||||
|
||||
// Determine if we're in production mode (CI or release profile)
|
||||
let is_production = env::var("CI").is_ok() || env::var("PROFILE").map(|p| p == "release").unwrap_or(false);
|
||||
|
||||
// Read git commit from .source-commit file
|
||||
let source_commit_path = workspace_root.join(".source-commit");
|
||||
let git_commit = if source_commit_path.exists() {
|
||||
match fs::read_to_string(&source_commit_path) {
|
||||
Ok(content) => content.trim().to_string(),
|
||||
Err(e) if is_production => {
|
||||
panic!("Failed to read .source-commit file in production: {}", e);
|
||||
}
|
||||
Err(_) => "unknown".to_string(),
|
||||
}
|
||||
} else {
|
||||
// Fallback to git command if file doesn't exist (local development)
|
||||
let git_result = std::process::Command::new("git").args(["rev-parse", "HEAD"]).current_dir(workspace_root).output().ok().and_then(|output| if output.status.success() { String::from_utf8(output.stdout).ok() } else { None }).map(|s| s.trim().to_string());
|
||||
|
||||
match git_result {
|
||||
Some(commit) => commit,
|
||||
None if is_production => {
|
||||
panic!("Failed to acquire git commit in production and .source-commit file does not exist");
|
||||
}
|
||||
None => "unknown".to_string(),
|
||||
}
|
||||
};
|
||||
|
||||
// Determine build time based on environment
|
||||
let build_time = if let Ok(epoch) = env::var("SOURCE_DATE_EPOCH") {
|
||||
// Use provided timestamp for reproducible builds
|
||||
match epoch.parse::<i64>().ok().and_then(|ts| chrono::DateTime::from_timestamp(ts, 0)).map(|dt| dt.to_rfc3339()) {
|
||||
Some(time) => time,
|
||||
None if is_production => {
|
||||
panic!("Failed to parse SOURCE_DATE_EPOCH in production: {}", epoch);
|
||||
}
|
||||
None => "unknown".to_string(),
|
||||
}
|
||||
} else if env::var("CI").is_ok() {
|
||||
// Generate fresh timestamp in CI
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
} else {
|
||||
// Static value for local development
|
||||
"dev".to_string()
|
||||
};
|
||||
|
||||
// Set environment variables for compile-time access
|
||||
println!("cargo:rustc-env=BUILD_GIT_COMMIT={}", git_commit);
|
||||
println!("cargo:rustc-env=BUILD_TIME={}", build_time);
|
||||
|
||||
// Only re-run the build script when specific files change
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
|
||||
// In CI, watch the .source-commit file if it exists
|
||||
if source_commit_path.exists() {
|
||||
println!("cargo:rerun-if-changed={}", source_commit_path.display());
|
||||
}
|
||||
|
||||
// In local development, watch .git/HEAD to detect branch switches
|
||||
// We intentionally don't watch the branch ref file to avoid spurious rebuilds
|
||||
if env::var("CI").is_err() {
|
||||
let git_head = workspace_root.join(".git").join("HEAD");
|
||||
if git_head.exists() {
|
||||
println!("cargo:rerun-if-changed={}", git_head.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
146
crates/borders-core/src/app.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
//! Minimal ECS app wrapper to replace Bevy's App
|
||||
|
||||
use bevy_ecs::message::{Message, Messages};
|
||||
use bevy_ecs::prelude::*;
|
||||
use bevy_ecs::schedule::{IntoScheduleConfigs, ScheduleLabel, Schedules};
|
||||
use bevy_ecs::system::ScheduleSystem;
|
||||
use std::fmt::Debug;
|
||||
|
||||
#[derive(Debug, Hash, PartialEq, Eq, Clone, ScheduleLabel)]
|
||||
pub struct Startup;
|
||||
|
||||
#[derive(Debug, Hash, PartialEq, Eq, Clone, ScheduleLabel)]
|
||||
pub struct Update;
|
||||
|
||||
#[derive(Debug, Hash, PartialEq, Eq, Clone, ScheduleLabel)]
|
||||
pub struct Last;
|
||||
|
||||
pub struct App {
|
||||
world: World,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new() -> Self {
|
||||
let mut world = World::new();
|
||||
|
||||
// Initialize schedules with proper ordering
|
||||
let mut schedules = Schedules::new();
|
||||
schedules.insert(Schedule::new(Startup));
|
||||
schedules.insert(Schedule::new(Update));
|
||||
schedules.insert(Schedule::new(Last));
|
||||
|
||||
world.insert_resource(schedules);
|
||||
|
||||
Self { world }
|
||||
}
|
||||
|
||||
pub fn world(&self) -> &World {
|
||||
&self.world
|
||||
}
|
||||
|
||||
pub fn world_mut(&mut self) -> &mut World {
|
||||
&mut self.world
|
||||
}
|
||||
|
||||
pub fn insert_resource<R: Resource>(&mut self, resource: R) -> &mut Self {
|
||||
self.world.insert_resource(resource);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn init_resource<R: Resource + FromWorld>(&mut self) -> &mut Self {
|
||||
self.world.init_resource::<R>();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn insert_non_send_resource<R: 'static>(&mut self, resource: R) -> &mut Self {
|
||||
self.world.insert_non_send_resource(resource);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn add_message<M: Message>(&mut self) -> &mut Self {
|
||||
if !self.world.contains_resource::<Messages<M>>() {
|
||||
self.world.init_resource::<Messages<M>>();
|
||||
|
||||
// Add system to update this message type each frame
|
||||
self.add_systems(Last, |mut messages: ResMut<Messages<M>>| {
|
||||
messages.update();
|
||||
});
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn add_systems<M>(&mut self, schedule: impl ScheduleLabel, systems: impl IntoScheduleConfigs<ScheduleSystem, M>) -> &mut Self {
|
||||
let mut schedules = self.world.resource_mut::<Schedules>();
|
||||
if let Some(schedule_inst) = schedules.get_mut(schedule) {
|
||||
schedule_inst.add_systems(systems);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn update(&mut self) {
|
||||
let _guard = tracing::trace_span!("app_update").entered();
|
||||
|
||||
// Remove schedules temporarily to avoid resource_scope conflicts
|
||||
let mut schedules = self.world.remove_resource::<Schedules>().unwrap();
|
||||
|
||||
// Run Update schedule
|
||||
if let Some(schedule) = schedules.get_mut(Update) {
|
||||
let _guard = tracing::trace_span!("update_schedule").entered();
|
||||
schedule.run(&mut self.world);
|
||||
}
|
||||
|
||||
// Run Last schedule (includes event updates)
|
||||
if let Some(schedule) = schedules.get_mut(Last) {
|
||||
let _guard = tracing::trace_span!("last_schedule").entered();
|
||||
schedule.run(&mut self.world);
|
||||
}
|
||||
|
||||
// Re-insert schedules
|
||||
self.world.insert_resource(schedules);
|
||||
}
|
||||
|
||||
pub fn run_startup(&mut self) {
|
||||
let _guard = tracing::trace_span!("run_startup_schedule").entered();
|
||||
|
||||
// Remove schedules temporarily to avoid resource_scope conflicts
|
||||
let mut schedules = self.world.remove_resource::<Schedules>().unwrap();
|
||||
|
||||
// Run Startup schedule
|
||||
if let Some(schedule) = schedules.get_mut(Startup) {
|
||||
schedule.run(&mut self.world);
|
||||
}
|
||||
|
||||
// Re-insert schedules
|
||||
self.world.insert_resource(schedules);
|
||||
}
|
||||
|
||||
pub fn finish(&mut self) {
|
||||
// Finalize schedules
|
||||
let mut schedules = self.world.remove_resource::<Schedules>().unwrap();
|
||||
|
||||
let system_count: usize = schedules.iter().map(|(_, schedule)| schedule.systems().map(|iter| iter.count()).unwrap_or(0)).sum();
|
||||
|
||||
let _guard = tracing::trace_span!("finish_schedules", system_count = system_count).entered();
|
||||
|
||||
for (_, schedule) in schedules.iter_mut() {
|
||||
schedule.graph_mut().initialize(&mut self.world);
|
||||
}
|
||||
|
||||
self.world.insert_resource(schedules);
|
||||
}
|
||||
|
||||
pub fn cleanup(&mut self) {
|
||||
// Any cleanup needed before running
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for App {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin trait for modular setup
|
||||
pub trait Plugin {
|
||||
fn build(&self, app: &mut App);
|
||||
}
|
||||
21
crates/borders-core/src/build_info.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
//! Build metadata injected at compile time
|
||||
|
||||
/// The version of the application from Cargo.toml
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// The git commit hash from .source-commit file or git command
|
||||
pub const GIT_COMMIT: &str = env!("BUILD_GIT_COMMIT");
|
||||
|
||||
/// The build timestamp in RFC3339 format (UTC)
|
||||
pub const BUILD_TIME: &str = env!("BUILD_TIME");
|
||||
|
||||
/// Get the git commit hash (short form, first 7 characters)
|
||||
pub fn git_commit_short() -> &'static str {
|
||||
let full = GIT_COMMIT;
|
||||
if full.len() >= 7 { &full[..7] } else { full }
|
||||
}
|
||||
|
||||
/// Full build information formatted as a string
|
||||
pub fn info() -> String {
|
||||
format!("Iron Borders v{} ({})\nBuilt: {}", VERSION, git_commit_short(), BUILD_TIME)
|
||||
}
|
||||
98
crates/borders-core/src/dns.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
//! Custom DNS resolver using Hickory DNS with DoH/DoT support.
|
||||
//!
|
||||
//! This module provides DNS over HTTPS (DoH) functionality for enhanced privacy,
|
||||
//! with automatic fallback to system DNS if DoH is unavailable.
|
||||
|
||||
use hickory_resolver::{
|
||||
TokioResolver,
|
||||
config::{NameServerConfigGroup, ResolverConfig},
|
||||
name_server::TokioConnectionProvider,
|
||||
};
|
||||
use once_cell::sync::OnceCell;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Custom DNS resolver for reqwest that uses Hickory DNS with DoH/DoT support.
|
||||
///
|
||||
/// This resolver is configured to use Cloudflare's DNS over HTTPS (1.1.1.1).
|
||||
/// DNS over HTTPS encrypts DNS queries, preventing eavesdropping and tampering.
|
||||
///
|
||||
/// The resolver is lazily initialized within the async context to ensure
|
||||
/// it's created within the Tokio runtime.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct HickoryDnsResolver {
|
||||
/// Lazily initialized resolver to ensure it's created within Tokio runtime context
|
||||
state: Arc<OnceCell<TokioResolver>>,
|
||||
}
|
||||
|
||||
impl HickoryDnsResolver {
|
||||
pub fn new() -> Self {
|
||||
Self { state: Arc::new(OnceCell::new()) }
|
||||
}
|
||||
|
||||
/// Initialize the Hickory DNS resolver with Cloudflare DoH configuration
|
||||
fn init_resolver() -> Result<TokioResolver, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut group: NameServerConfigGroup = NameServerConfigGroup::google();
|
||||
group.merge(NameServerConfigGroup::cloudflare());
|
||||
group.merge(NameServerConfigGroup::quad9());
|
||||
group.merge(NameServerConfigGroup::google());
|
||||
|
||||
let mut config = ResolverConfig::new();
|
||||
for server in group.iter() {
|
||||
config.add_name_server(server.clone());
|
||||
}
|
||||
|
||||
// Use tokio() constructor which properly integrates with current Tokio runtime
|
||||
let resolver = TokioResolver::builder_with_config(config, TokioConnectionProvider::default()).build();
|
||||
|
||||
debug!("DNS resolver initialized with Cloudflare DoH");
|
||||
Ok(resolver)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback to system DNS when DoH is unavailable
|
||||
async fn fallback_to_system_dns(name: &str) -> Result<Box<dyn Iterator<Item = SocketAddr> + Send>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
use tokio::net::lookup_host;
|
||||
|
||||
let addrs: Vec<SocketAddr> = lookup_host(format!("{}:443", name))
|
||||
.await?
|
||||
.map(|mut addr| {
|
||||
addr.set_port(0);
|
||||
addr
|
||||
})
|
||||
.collect();
|
||||
|
||||
debug!("Resolved '{}' via system DNS ({} addresses)", name, addrs.len());
|
||||
Ok(Box::new(addrs.into_iter()))
|
||||
}
|
||||
|
||||
impl reqwest::dns::Resolve for HickoryDnsResolver {
|
||||
fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
|
||||
let resolver_state = self.state.clone();
|
||||
let name_str = name.as_str().to_string();
|
||||
|
||||
Box::pin(async move {
|
||||
// Get or initialize the resolver within the async context (Tokio runtime)
|
||||
let resolver = match resolver_state.get_or_try_init(Self::init_resolver) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!("Failed to initialize DoH resolver: {}, using system DNS", e);
|
||||
return fallback_to_system_dns(&name_str).await;
|
||||
}
|
||||
};
|
||||
|
||||
// Try Hickory DNS first (DoH)
|
||||
match resolver.lookup_ip(format!("{}.", name_str)).await {
|
||||
Ok(lookup) => {
|
||||
let addrs: reqwest::dns::Addrs = Box::new(lookup.into_iter().map(|ip| SocketAddr::new(ip, 0)));
|
||||
Ok(addrs)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("DoH lookup failed for '{}': {}, falling back to system DNS", name_str, e);
|
||||
fallback_to_system_dns(&name_str).await
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
455
crates/borders-core/src/game/ai/bot.rs
Normal file
@@ -0,0 +1,455 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use glam::{IVec2, U16Vec2};
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{Rng, SeedableRng};
|
||||
|
||||
use crate::game::SpawnPoint;
|
||||
use crate::game::core::action::GameAction;
|
||||
use crate::game::core::constants::bot::*;
|
||||
use crate::game::core::utils::neighbors;
|
||||
use crate::game::terrain::data::TerrainData;
|
||||
use crate::game::world::{NationId, TerritoryManager};
|
||||
|
||||
/// Bot AI component - stores per-bot state for decision making
|
||||
#[derive(Component)]
|
||||
pub struct Bot {
|
||||
pub last_action_tick: u64,
|
||||
pub action_cooldown: u64,
|
||||
}
|
||||
|
||||
impl Default for Bot {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Bot {
|
||||
pub fn new() -> Self {
|
||||
let mut rng = rand::rng();
|
||||
Self { last_action_tick: 0, action_cooldown: rng.random_range(0..INITIAL_COOLDOWN_MAX) }
|
||||
}
|
||||
|
||||
/// Sample a random subset of border tiles to reduce O(n) iteration cost
|
||||
fn sample_border_tiles(border_tiles: &HashSet<U16Vec2>, border_count: usize, rng: &mut StdRng) -> Vec<U16Vec2> {
|
||||
if border_count <= MAX_BORDER_SAMPLES {
|
||||
border_tiles.iter().copied().collect()
|
||||
} else {
|
||||
// Random sampling without replacement using Fisher-Yates
|
||||
let mut border_vec: Vec<U16Vec2> = border_tiles.iter().copied().collect();
|
||||
|
||||
// Partial Fisher-Yates shuffle for first MAX_BORDER_SAMPLES elements
|
||||
for i in 0..MAX_BORDER_SAMPLES {
|
||||
let j = rng.random_range(i..border_count);
|
||||
border_vec.swap(i, j);
|
||||
}
|
||||
|
||||
border_vec.truncate(MAX_BORDER_SAMPLES);
|
||||
border_vec
|
||||
}
|
||||
}
|
||||
|
||||
/// Tick the bot AI - now deterministic based on turn number and RNG seed
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn tick(&mut self, turn_number: u64, player_id: NationId, troops: &crate::game::Troops, territory_manager: &TerritoryManager, terrain: &TerrainData, player_borders: &HashMap<NationId, &HashSet<U16Vec2>>, rng_seed: u64) -> Option<GameAction> {
|
||||
// Only act every few ticks
|
||||
if turn_number < self.last_action_tick + self.action_cooldown {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.last_action_tick = turn_number;
|
||||
|
||||
// Deterministic RNG based on turn number, player ID, and global seed
|
||||
let seed = rng_seed.wrapping_add(turn_number).wrapping_add(player_id.get() as u64);
|
||||
let mut rng = StdRng::seed_from_u64(seed);
|
||||
self.action_cooldown = rng.random_range(ACTION_COOLDOWN_MIN..ACTION_COOLDOWN_MAX);
|
||||
|
||||
// Decide action: expand into wilderness or attack a neighbor
|
||||
let _guard = tracing::trace_span!("bot_tick", player_id = %player_id).entered();
|
||||
|
||||
let action_type: f32 = rng.random();
|
||||
|
||||
if action_type < EXPAND_PROBABILITY {
|
||||
// Expand into wilderness (60% chance)
|
||||
self.expand_wilderness(player_id, troops, territory_manager, terrain, player_borders, &mut rng)
|
||||
} else {
|
||||
// Attack a neighbor (40% chance)
|
||||
self.attack_neighbor(player_id, troops, territory_manager, terrain, player_borders, &mut rng)
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand into unclaimed territory
|
||||
fn expand_wilderness(&self, player_id: NationId, troops: &crate::game::Troops, territory_manager: &TerritoryManager, terrain: &TerrainData, player_borders: &HashMap<NationId, &HashSet<U16Vec2>>, rng: &mut StdRng) -> Option<GameAction> {
|
||||
let border_tiles = player_borders.get(&player_id)?;
|
||||
let border_count = border_tiles.len();
|
||||
|
||||
let _guard = tracing::trace_span!("expand_wilderness", border_count).entered();
|
||||
|
||||
let size = territory_manager.size();
|
||||
|
||||
let tiles_to_check = Self::sample_border_tiles(border_tiles, border_count, rng);
|
||||
|
||||
// Find a valid, unclaimed neighbor tile to attack
|
||||
for &tile in &tiles_to_check {
|
||||
if let Some(_neighbor) = neighbors(tile, size).find(|&neighbor| !territory_manager.has_owner(neighbor) && terrain.is_conquerable(neighbor)) {
|
||||
let troop_percentage: f32 = rng.random_range(EXPAND_TROOPS_MIN..EXPAND_TROOPS_MAX);
|
||||
let troop_count = (troops.0 * troop_percentage).floor() as u32;
|
||||
return Some(GameAction::Attack { target: None, troops: troop_count });
|
||||
}
|
||||
}
|
||||
|
||||
tracing::trace!(player_id = ?player_id, "No wilderness target found");
|
||||
None
|
||||
}
|
||||
|
||||
/// Attack a neighboring player
|
||||
fn attack_neighbor(&self, player_id: NationId, troops: &crate::game::Troops, territory_manager: &TerritoryManager, _terrain: &TerrainData, player_borders: &HashMap<NationId, &HashSet<U16Vec2>>, rng: &mut StdRng) -> Option<GameAction> {
|
||||
let border_tiles = player_borders.get(&player_id)?;
|
||||
let border_count = border_tiles.len();
|
||||
|
||||
let _guard = tracing::trace_span!("attack_neighbor", border_count).entered();
|
||||
|
||||
// Find neighboring players
|
||||
let mut neighboring_nations = HashSet::new();
|
||||
let size = territory_manager.size();
|
||||
|
||||
let tiles_to_check = Self::sample_border_tiles(border_tiles, border_count, rng);
|
||||
|
||||
for &tile in &tiles_to_check {
|
||||
neighboring_nations.extend(neighbors(tile, size).filter_map(|neighbor| {
|
||||
let ownership = territory_manager.get_ownership(neighbor);
|
||||
ownership.nation_id().filter(|&nation_id| nation_id != player_id)
|
||||
}));
|
||||
}
|
||||
|
||||
if neighboring_nations.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Pick a random neighbor to attack
|
||||
let neighbor_count = neighboring_nations.len();
|
||||
let target_id = neighboring_nations.into_iter().nth(rng.random_range(0..neighbor_count)).unwrap();
|
||||
|
||||
let troop_percentage: f32 = rng.random_range(ATTACK_TROOPS_MIN..ATTACK_TROOPS_MAX);
|
||||
let troop_count = (troops.0 * troop_percentage).floor() as u32;
|
||||
Some(GameAction::Attack { target: Some(target_id), troops: troop_count })
|
||||
}
|
||||
}
|
||||
|
||||
/// Spatial grid for fast spawn collision detection
|
||||
/// Divides map into cells for O(1) neighbor queries instead of O(n)
|
||||
struct SpawnGrid {
|
||||
grid: HashMap<IVec2, Vec<U16Vec2>>,
|
||||
cell_size: f32,
|
||||
}
|
||||
|
||||
impl SpawnGrid {
|
||||
fn new(cell_size: f32) -> Self {
|
||||
Self { grid: HashMap::new(), cell_size }
|
||||
}
|
||||
|
||||
fn insert(&mut self, pos: U16Vec2) {
|
||||
let cell = self.pos_to_cell(pos);
|
||||
self.grid.entry(cell).or_default().push(pos);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn pos_to_cell(&self, pos: U16Vec2) -> IVec2 {
|
||||
let x = pos.x as f32 / self.cell_size;
|
||||
let y = pos.y as f32 / self.cell_size;
|
||||
IVec2::new(x as i32, y as i32)
|
||||
}
|
||||
|
||||
fn has_nearby(&self, pos: U16Vec2, radius: f32) -> bool {
|
||||
let cell = self.pos_to_cell(pos);
|
||||
let cell_radius = (radius / self.cell_size).ceil() as i32;
|
||||
|
||||
for dx in -cell_radius..=cell_radius {
|
||||
for dy in -cell_radius..=cell_radius {
|
||||
let check_cell = cell + IVec2::new(dx, dy);
|
||||
if let Some(positions) = self.grid.get(&check_cell) {
|
||||
for &existing_pos in positions {
|
||||
if calculate_position_distance(pos, existing_pos) < radius {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate Euclidean distance between two positions
|
||||
#[inline]
|
||||
fn calculate_position_distance(pos1: U16Vec2, pos2: U16Vec2) -> f32 {
|
||||
pos1.as_vec2().distance(pos2.as_vec2())
|
||||
}
|
||||
|
||||
/// Calculate initial bot spawn positions (first pass)
|
||||
///
|
||||
/// Places bots at random valid locations with adaptive spacing.
|
||||
/// Uses spatial grid for O(1) neighbor checks and adaptively reduces
|
||||
/// minimum distance when map becomes crowded.
|
||||
///
|
||||
/// Guarantees all bots spawn (no silent drops). This is deterministic based on rng_seed.
|
||||
///
|
||||
/// Returns Vec<SpawnPoint> for each bot
|
||||
pub fn calculate_initial_spawns(bot_player_ids: &[NationId], territory_manager: &TerritoryManager, terrain: &TerrainData, rng_seed: u64) -> Vec<SpawnPoint> {
|
||||
let _guard = tracing::trace_span!("calculate_initial_spawns", bot_count = bot_player_ids.len()).entered();
|
||||
|
||||
let size = territory_manager.size();
|
||||
|
||||
let mut spawn_positions = Vec::with_capacity(bot_player_ids.len());
|
||||
let mut grid = SpawnGrid::new(MIN_SPAWN_DISTANCE);
|
||||
let mut current_min_distance = MIN_SPAWN_DISTANCE;
|
||||
|
||||
for (bot_index, &player_id) in bot_player_ids.iter().enumerate() {
|
||||
// Deterministic RNG for spawn location
|
||||
let seed = rng_seed.wrapping_add(player_id.get() as u64).wrapping_add(bot_index as u64);
|
||||
let mut rng = StdRng::seed_from_u64(seed);
|
||||
|
||||
let mut placed = false;
|
||||
|
||||
// Try with current minimum distance
|
||||
while !placed && current_min_distance >= ABSOLUTE_MIN_DISTANCE {
|
||||
// Phase 1: Random sampling
|
||||
for _ in 0..SPAWN_RANDOM_ATTEMPTS {
|
||||
let tile_pos = U16Vec2::new(rng.random_range(0..size.x), rng.random_range(0..size.y));
|
||||
|
||||
// Check if tile is valid land
|
||||
if territory_manager.has_owner(tile_pos) || !terrain.is_conquerable(tile_pos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check distance using spatial grid (O(1) instead of O(n))
|
||||
if !grid.has_nearby(tile_pos, current_min_distance) {
|
||||
spawn_positions.push(SpawnPoint::new(player_id, tile_pos));
|
||||
grid.insert(tile_pos);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Grid-guided fallback (if random sampling failed)
|
||||
if !placed {
|
||||
// Try a systematic grid search with stride
|
||||
let stride = (current_min_distance * SPAWN_GRID_STRIDE_FACTOR) as u16;
|
||||
let mut attempts = 0;
|
||||
for y in (0..size.y).step_by(stride.max(1) as usize) {
|
||||
for x in (0..size.x).step_by(stride.max(1) as usize) {
|
||||
let tile_pos = U16Vec2::new(x, y);
|
||||
|
||||
if territory_manager.has_owner(tile_pos) || !terrain.is_conquerable(tile_pos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !grid.has_nearby(tile_pos, current_min_distance) {
|
||||
spawn_positions.push(SpawnPoint::new(player_id, tile_pos));
|
||||
grid.insert(tile_pos);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
attempts += 1;
|
||||
if attempts > SPAWN_GRID_MAX_ATTEMPTS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if placed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Reduce minimum distance and retry
|
||||
if !placed {
|
||||
current_min_distance *= DISTANCE_REDUCTION_FACTOR;
|
||||
if bot_index % 100 == 0 && current_min_distance < MIN_SPAWN_DISTANCE {
|
||||
tracing::debug!("Adaptive spawn: reduced min_distance to {:.1} for bot {}", current_min_distance, bot_index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback: Place at any valid land tile (guaranteed)
|
||||
if !placed {
|
||||
for _ in 0..SPAWN_FALLBACK_ATTEMPTS {
|
||||
let tile_pos = U16Vec2::new(rng.random_range(0..size.x), rng.random_range(0..size.y));
|
||||
if !territory_manager.has_owner(tile_pos) && terrain.is_conquerable(tile_pos) {
|
||||
spawn_positions.push(SpawnPoint::new(player_id, tile_pos));
|
||||
grid.insert(tile_pos);
|
||||
placed = true;
|
||||
tracing::warn!("Bot {} placed with fallback (no distance constraint)", player_id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !placed {
|
||||
tracing::error!("Failed to place bot {} after all attempts", player_id);
|
||||
}
|
||||
}
|
||||
|
||||
spawn_positions
|
||||
}
|
||||
|
||||
/// Recalculate bot spawns considering player positions (second pass)
|
||||
///
|
||||
/// For any bot that is too close to a player spawn, find a new position.
|
||||
/// Uses adaptive algorithm with grid acceleration to guarantee all displaced
|
||||
/// bots find new positions. This maintains determinism while ensuring proper spawn spacing.
|
||||
///
|
||||
/// Arguments:
|
||||
/// - `initial_bot_spawns`: Bot positions from first pass
|
||||
/// - `player_spawns`: Human player spawn positions
|
||||
/// - `territory_manager`: For checking valid tiles
|
||||
/// - `terrain`: For checking conquerable tiles
|
||||
/// - `rng_seed`: For deterministic relocation
|
||||
///
|
||||
/// Returns updated Vec<SpawnPoint> with relocated bots
|
||||
pub fn recalculate_spawns_with_players(initial_bot_spawns: Vec<SpawnPoint>, player_spawns: &[SpawnPoint], territory_manager: &TerritoryManager, terrain: &TerrainData, rng_seed: u64) -> Vec<SpawnPoint> {
|
||||
let _guard = tracing::trace_span!("recalculate_spawns_with_players", bot_count = initial_bot_spawns.len(), player_count = player_spawns.len()).entered();
|
||||
|
||||
let size = territory_manager.size();
|
||||
|
||||
// Build spatial grid to track occupied spawn locations
|
||||
// Contains all player spawns plus bots that don't need relocation
|
||||
// Enables O(1) distance checks instead of O(n) iteration
|
||||
let mut grid = SpawnGrid::new(MIN_SPAWN_DISTANCE);
|
||||
for spawn in player_spawns {
|
||||
grid.insert(spawn.tile);
|
||||
}
|
||||
|
||||
// Partition bots into two groups:
|
||||
// 1. Bots that are far enough from all player spawns (keep as-is)
|
||||
// 2. Bots that violate MIN_SPAWN_DISTANCE from any player (need relocation)
|
||||
let mut bots_to_relocate = Vec::new();
|
||||
let mut final_spawns = Vec::new();
|
||||
|
||||
for spawn in initial_bot_spawns {
|
||||
let mut needs_relocation = false;
|
||||
|
||||
for player_spawn in player_spawns {
|
||||
if calculate_position_distance(spawn.tile, player_spawn.tile) < MIN_SPAWN_DISTANCE {
|
||||
needs_relocation = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if needs_relocation {
|
||||
bots_to_relocate.push(spawn.nation);
|
||||
} else {
|
||||
// Bot is valid - add to final list and mark space as occupied
|
||||
final_spawns.push(spawn);
|
||||
grid.insert(spawn.tile);
|
||||
}
|
||||
}
|
||||
|
||||
// Relocate displaced bots using a three-phase adaptive algorithm:
|
||||
// Phase 1: Random sampling (fast, works well when space is available)
|
||||
// Phase 2: Grid-based systematic search (fallback when random fails)
|
||||
// Phase 3: Adaptive distance reduction (progressively relax spacing constraints)
|
||||
//
|
||||
// This adaptively reduces spacing as the map fills up, ensuring all bots
|
||||
// eventually find placement even on crowded maps
|
||||
let mut current_min_distance = MIN_SPAWN_DISTANCE;
|
||||
|
||||
for (reloc_index, &player_id) in bots_to_relocate.iter().enumerate() {
|
||||
// Deterministic RNG with a different seed offset to avoid reusing original positions
|
||||
let seed = rng_seed.wrapping_add(player_id.get() as u64).wrapping_add(0xDEADBEEF);
|
||||
let mut rng = StdRng::seed_from_u64(seed);
|
||||
|
||||
let mut placed = false;
|
||||
|
||||
// Keep trying with progressively relaxed distance constraints
|
||||
while !placed && current_min_distance >= ABSOLUTE_MIN_DISTANCE {
|
||||
// Phase 1: Random sampling - try random tiles until we find a valid spot
|
||||
// Fast and evenly distributed when sufficient space exists
|
||||
for _ in 0..SPAWN_RANDOM_ATTEMPTS {
|
||||
let tile_pos = U16Vec2::new(rng.random_range(0..size.x), rng.random_range(0..size.y));
|
||||
|
||||
// Skip tiles that are already owned or unconquerable (water/mountains)
|
||||
if territory_manager.has_owner(tile_pos) || !terrain.is_conquerable(tile_pos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this tile is far enough from all existing spawns
|
||||
// Grid lookup is O(1) - only checks cells within radius, not all spawns
|
||||
if !grid.has_nearby(tile_pos, current_min_distance) {
|
||||
final_spawns.push(SpawnPoint::new(player_id, tile_pos));
|
||||
grid.insert(tile_pos);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Grid-based systematic search
|
||||
// When random sampling fails (map is crowded), use a strided grid search
|
||||
// to systematically check evenly-spaced candidate positions
|
||||
if !placed {
|
||||
// Stride determines spacing between checked positions (larger = faster but might miss spots)
|
||||
let stride = (current_min_distance * SPAWN_GRID_STRIDE_FACTOR) as u16;
|
||||
let mut attempts = 0;
|
||||
|
||||
for y in (0..size.y).step_by(stride.max(1) as usize) {
|
||||
for x in (0..size.x).step_by(stride.max(1) as usize) {
|
||||
let tile_pos = U16Vec2::new(x, y);
|
||||
|
||||
if territory_manager.has_owner(tile_pos) || !terrain.is_conquerable(tile_pos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !grid.has_nearby(tile_pos, current_min_distance) {
|
||||
final_spawns.push(SpawnPoint::new(player_id, tile_pos));
|
||||
grid.insert(tile_pos);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Prevent infinite loops on maps with very little valid space
|
||||
attempts += 1;
|
||||
if attempts > SPAWN_GRID_MAX_ATTEMPTS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if placed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Adaptive distance reduction
|
||||
// If both random and grid search failed, the map is too crowded
|
||||
// Reduce minimum spacing requirement and retry both phases
|
||||
if !placed {
|
||||
current_min_distance *= DISTANCE_REDUCTION_FACTOR;
|
||||
if reloc_index % 50 == 0 && current_min_distance < MIN_SPAWN_DISTANCE {
|
||||
tracing::debug!("Adaptive relocation: reduced min_distance to {:.1} for bot {}", current_min_distance, reloc_index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback: ignore all distance constraints
|
||||
// Guarantees placement even on extremely crowded maps
|
||||
// Simply finds any valid conquerable tile
|
||||
if !placed {
|
||||
for _ in 0..SPAWN_FALLBACK_ATTEMPTS {
|
||||
let tile_pos = U16Vec2::new(rng.random_range(0..size.x), rng.random_range(0..size.y));
|
||||
if !territory_manager.has_owner(tile_pos) && terrain.is_conquerable(tile_pos) {
|
||||
final_spawns.push(SpawnPoint::new(player_id, tile_pos));
|
||||
grid.insert(tile_pos);
|
||||
placed = true;
|
||||
tracing::warn!("Bot {} relocated with fallback (no distance constraint)", player_id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !placed {
|
||||
tracing::error!("Failed to relocate bot {} after all attempts", player_id);
|
||||
}
|
||||
}
|
||||
|
||||
final_spawns
|
||||
}
|
||||
7
crates/borders-core/src/game/ai/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
//! AI and bot player logic
|
||||
//!
|
||||
//! This module contains the bot manager and AI decision-making logic.
|
||||
|
||||
pub mod bot;
|
||||
|
||||
pub use bot::*;
|
||||
374
crates/borders-core/src/game/combat/active.rs
Normal file
@@ -0,0 +1,374 @@
|
||||
/// Active attacks management
|
||||
///
|
||||
/// This module manages all ongoing attacks in the game. It provides efficient
|
||||
/// lookup and coordination of attacks, ensuring proper merging of attacks on
|
||||
/// the same target and handling counter-attacks.
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use glam::U16Vec2;
|
||||
use slotmap::{SlotMap, new_key_type};
|
||||
|
||||
new_key_type! {
|
||||
/// Unique key for identifying attacks in the SlotMap
|
||||
pub struct AttackKey;
|
||||
}
|
||||
|
||||
use super::executor::{AttackConfig, AttackExecutor};
|
||||
use crate::game::NationId;
|
||||
use crate::game::core::rng::DeterministicRng;
|
||||
use crate::game::entities::{HumanPlayerCount, PlayerEntityMap, TerritorySize, Troops};
|
||||
use crate::game::world::TerritoryManager;
|
||||
|
||||
/// Index structure for efficient attack lookups
|
||||
///
|
||||
/// Maintains multiple indices for O(1) lookups by different criteria.
|
||||
/// All methods maintain index consistency automatically - you cannot
|
||||
/// accidentally update one index without updating the others.
|
||||
struct AttackIndex {
|
||||
/// (attacker, target) -> set of attack keys
|
||||
/// Multiple attacks can exist between same pair (islands, ship landings, etc.)
|
||||
player_index: HashMap<(NationId, NationId), HashSet<AttackKey>>,
|
||||
|
||||
/// attacker -> attack key for unclaimed territory
|
||||
/// Only one unclaimed attack per player
|
||||
unclaimed_index: HashMap<NationId, AttackKey>,
|
||||
|
||||
/// player -> attacks where player is attacker
|
||||
player_attack_list: HashMap<NationId, HashSet<AttackKey>>,
|
||||
|
||||
/// player -> attacks where player is target
|
||||
target_attack_list: HashMap<NationId, HashSet<AttackKey>>,
|
||||
}
|
||||
|
||||
impl AttackIndex {
|
||||
fn new() -> Self {
|
||||
Self { player_index: HashMap::new(), unclaimed_index: HashMap::new(), player_attack_list: HashMap::new(), target_attack_list: HashMap::new() }
|
||||
}
|
||||
|
||||
fn clear(&mut self) {
|
||||
self.player_index.clear();
|
||||
self.unclaimed_index.clear();
|
||||
self.player_attack_list.clear();
|
||||
self.target_attack_list.clear();
|
||||
}
|
||||
|
||||
/// Get existing unclaimed attack for a player
|
||||
fn get_unclaimed_attack(&self, player_id: NationId) -> Option<AttackKey> {
|
||||
self.unclaimed_index.get(&player_id).copied()
|
||||
}
|
||||
|
||||
/// Get first existing attack on a target (for merging troops)
|
||||
fn get_existing_attack(&self, player_id: NationId, target_id: NationId) -> Option<AttackKey> {
|
||||
self.player_index.get(&(player_id, target_id))?.iter().next().copied()
|
||||
}
|
||||
|
||||
/// Check if counter-attacks exist (opposite direction)
|
||||
fn has_counter_attacks(&self, player_id: NationId, target_id: NationId) -> bool {
|
||||
self.player_index.get(&(target_id, player_id)).is_some_and(|set| !set.is_empty())
|
||||
}
|
||||
|
||||
/// Get first counter-attack key (for resolution)
|
||||
fn get_counter_attack(&self, player_id: NationId, target_id: NationId) -> Option<AttackKey> {
|
||||
self.player_index.get(&(target_id, player_id))?.iter().next().copied()
|
||||
}
|
||||
|
||||
/// Get all attacks where player is attacker
|
||||
fn get_attacks_by_player(&self, player_id: NationId) -> Option<&HashSet<AttackKey>> {
|
||||
self.player_attack_list.get(&player_id)
|
||||
}
|
||||
|
||||
/// Get all attacks where player is target
|
||||
fn get_attacks_on_player(&self, player_id: NationId) -> Option<&HashSet<AttackKey>> {
|
||||
self.target_attack_list.get(&player_id)
|
||||
}
|
||||
|
||||
/// Add a player-vs-player attack to all indices atomically
|
||||
fn add_player_attack(&mut self, player_id: NationId, target_id: NationId, key: AttackKey) {
|
||||
// Invariant: Cannot attack yourself
|
||||
if player_id == target_id {
|
||||
tracing::error!(
|
||||
player_id = %player_id,
|
||||
attack_key = ?key,
|
||||
"Attempted to add self-attack to index (invariant violation)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
self.player_index.entry((player_id, target_id)).or_default().insert(key);
|
||||
self.player_attack_list.entry(player_id).or_default().insert(key);
|
||||
self.target_attack_list.entry(target_id).or_default().insert(key);
|
||||
}
|
||||
|
||||
/// Add an unclaimed territory attack to all indices atomically
|
||||
fn add_unclaimed_attack(&mut self, player_id: NationId, key: AttackKey) {
|
||||
self.unclaimed_index.insert(player_id, key);
|
||||
self.player_attack_list.entry(player_id).or_default().insert(key);
|
||||
}
|
||||
|
||||
/// Remove a player-vs-player attack from all indices atomically
|
||||
fn remove_player_attack(&mut self, player_id: NationId, target_id: NationId, key: AttackKey) {
|
||||
if let Some(attack_set) = self.player_attack_list.get_mut(&player_id) {
|
||||
attack_set.remove(&key);
|
||||
}
|
||||
if let Some(attack_set) = self.target_attack_list.get_mut(&target_id) {
|
||||
attack_set.remove(&key);
|
||||
}
|
||||
if let Some(attack_set) = self.player_index.get_mut(&(player_id, target_id)) {
|
||||
attack_set.remove(&key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove an unclaimed territory attack from all indices atomically
|
||||
fn remove_unclaimed_attack(&mut self, player_id: NationId, key: AttackKey) {
|
||||
self.unclaimed_index.remove(&player_id);
|
||||
if let Some(attack_set) = self.player_attack_list.get_mut(&player_id) {
|
||||
attack_set.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages all active attacks in the game
|
||||
///
|
||||
/// This resource tracks ongoing attacks and provides efficient lookup
|
||||
/// by attacker/target relationships. Attacks progress over multiple turns
|
||||
/// until they run out of troops or conquerable tiles.
|
||||
///
|
||||
/// Uses SlotMap for stable keys - no index shifting needed on removal.
|
||||
/// Uses AttackIndex for consistent multi-index management.
|
||||
#[derive(Resource)]
|
||||
pub struct ActiveAttacks {
|
||||
attacks: SlotMap<AttackKey, AttackExecutor>,
|
||||
index: AttackIndex,
|
||||
next_attack_id: u64,
|
||||
}
|
||||
|
||||
impl Default for ActiveAttacks {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveAttacks {
|
||||
pub fn new() -> Self {
|
||||
Self { attacks: SlotMap::with_key(), index: AttackIndex::new(), next_attack_id: 0 }
|
||||
}
|
||||
|
||||
/// Initialize the attack handler
|
||||
pub fn init(&mut self, _max_players: usize) {
|
||||
self.attacks.clear();
|
||||
self.index.clear();
|
||||
self.next_attack_id = 0;
|
||||
}
|
||||
|
||||
/// Schedule an attack on unclaimed territory
|
||||
///
|
||||
/// If an attack on unclaimed territory already exists for this player,
|
||||
/// the troops are added to it and borders are expanded.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn schedule_unclaimed(&mut self, player_id: NationId, troops: f32, border_tiles: Option<&HashSet<U16Vec2>>, territory_manager: &TerritoryManager, terrain: &crate::game::terrain::TerrainData, player_borders: &HashMap<NationId, &HashSet<U16Vec2>>, turn_number: u64, rng: &DeterministicRng) {
|
||||
// Check if there's already an attack on unclaimed territory
|
||||
if let Some(attack_key) = self.index.get_unclaimed_attack(player_id) {
|
||||
// Add troops to existing attack
|
||||
self.attacks[attack_key].modify_troops(troops);
|
||||
|
||||
// Add new borders to allow multi-region expansion
|
||||
if let Some(borders) = border_tiles.or_else(|| player_borders.get(&player_id).copied()) {
|
||||
self.attacks[attack_key].add_borders(borders, territory_manager, terrain, rng);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new attack
|
||||
self.add_unclaimed(player_id, troops, border_tiles, territory_manager, terrain, player_borders, turn_number, rng);
|
||||
}
|
||||
|
||||
/// Schedule an attack on another player
|
||||
///
|
||||
/// Handles attack merging (if attacking same target) and counter-attacks
|
||||
/// (opposite direction attacks are resolved first).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn schedule_attack(&mut self, player_id: NationId, target_id: NationId, mut troops: f32, border_tiles: Option<&HashSet<U16Vec2>>, territory_manager: &TerritoryManager, terrain: &crate::game::terrain::TerrainData, player_borders: &HashMap<NationId, &HashSet<U16Vec2>>, turn_number: u64, rng: &DeterministicRng) {
|
||||
// Prevent self-attacks early (before any processing)
|
||||
if player_id == target_id {
|
||||
tracing::warn!(
|
||||
player_id = %player_id,
|
||||
"Attempted self-attack prevented"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there's already an attack on this target
|
||||
if let Some(attack_key) = self.index.get_existing_attack(player_id, target_id) {
|
||||
// Add troops to existing attack
|
||||
self.attacks[attack_key].modify_troops(troops);
|
||||
|
||||
// Add new borders to allow multi-region expansion
|
||||
if let Some(borders) = border_tiles.or_else(|| player_borders.get(&player_id).copied()) {
|
||||
self.attacks[attack_key].add_borders(borders, territory_manager, terrain, rng);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for counter-attacks (opposite direction) - prevent mutual attacks
|
||||
while self.index.has_counter_attacks(player_id, target_id) {
|
||||
let opposite_key = self.index.get_counter_attack(player_id, target_id).unwrap();
|
||||
|
||||
if self.attacks[opposite_key].oppose(troops) {
|
||||
// Counter-attack absorbed the new attack
|
||||
return;
|
||||
}
|
||||
|
||||
// Counter-attack was defeated, deduct its troops from the new attack
|
||||
troops -= self.attacks[opposite_key].get_troops();
|
||||
|
||||
// Remove the defeated counter-attack
|
||||
self.remove_attack(opposite_key);
|
||||
}
|
||||
|
||||
// Create new attack
|
||||
self.add_attack(player_id, target_id, troops, border_tiles, territory_manager, terrain, player_borders, turn_number, rng);
|
||||
}
|
||||
|
||||
/// Tick all active attacks
|
||||
///
|
||||
/// Progresses each attack by one turn. Attacks that run out of troops
|
||||
/// or conquerable tiles are removed and their remaining troops are
|
||||
/// returned to the attacking player.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn tick(&mut self, entity_map: &PlayerEntityMap, players: &mut Query<(&mut Troops, &mut TerritorySize)>, _commands: &mut Commands, territory_manager: &mut TerritoryManager, terrain: &crate::game::terrain::TerrainData, player_borders: &HashMap<NationId, &HashSet<U16Vec2>>, rng: &DeterministicRng, human_count: &HumanPlayerCount) {
|
||||
let attack_count = self.attacks.len();
|
||||
let _guard = tracing::trace_span!("attacks_tick", attack_count).entered();
|
||||
|
||||
let mut attacks_to_remove = Vec::new();
|
||||
|
||||
for (attack_key, attack) in &mut self.attacks {
|
||||
let should_continue = attack.tick(entity_map, players, territory_manager, terrain, player_borders, rng);
|
||||
|
||||
if !should_continue {
|
||||
// Return remaining troops to player (ECS component)
|
||||
let player_id = attack.player_id;
|
||||
let remaining_troops = attack.get_troops();
|
||||
|
||||
if let Some(&entity) = entity_map.0.get(&player_id)
|
||||
&& let Ok((mut troops, territory_size)) = players.get_mut(entity)
|
||||
{
|
||||
let is_bot = player_id.get() >= human_count.0;
|
||||
troops.0 = crate::game::entities::add_troops_capped(troops.0, remaining_troops, territory_size.0, is_bot);
|
||||
}
|
||||
|
||||
// Mark attack for removal
|
||||
attacks_to_remove.push(attack_key);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove completed attacks
|
||||
for attack_key in attacks_to_remove {
|
||||
self.remove_attack(attack_key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a tile being added to a player's territory
|
||||
///
|
||||
/// Notifies all relevant attacks that territory has changed so they can
|
||||
/// update their borders and targets.
|
||||
pub fn handle_territory_add(&mut self, tile: U16Vec2, player_id: NationId, territory_manager: &TerritoryManager, terrain: &crate::game::terrain::TerrainData, rng: &DeterministicRng) {
|
||||
// Notify all attacks where this player is the attacker
|
||||
if let Some(attack_set) = self.index.get_attacks_by_player(player_id) {
|
||||
for &attack_key in attack_set {
|
||||
self.attacks[attack_key].handle_player_tile_add(tile, territory_manager, terrain, rng);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify all attacks where this player is the target
|
||||
if let Some(attack_set) = self.index.get_attacks_on_player(player_id) {
|
||||
for &attack_key in attack_set {
|
||||
self.attacks[attack_key].handle_target_tile_add(tile, territory_manager, rng);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an attack on unclaimed territory
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn add_unclaimed(&mut self, player_id: NationId, troops: f32, border_tiles: Option<&HashSet<U16Vec2>>, territory_manager: &TerritoryManager, terrain: &crate::game::terrain::TerrainData, player_borders: &HashMap<NationId, &HashSet<U16Vec2>>, turn_number: u64, rng: &DeterministicRng) {
|
||||
let attack_id = self.next_attack_id;
|
||||
self.next_attack_id += 1;
|
||||
|
||||
let attack = AttackExecutor::new(AttackConfig { attack_id, player_id, target_id: None, troops, border_tiles, territory_manager, player_borders, turn_number, terrain }, rng);
|
||||
|
||||
let attack_key = self.attacks.insert(attack);
|
||||
self.index.add_unclaimed_attack(player_id, attack_key);
|
||||
}
|
||||
|
||||
/// Add an attack on a player
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn add_attack(&mut self, player_id: NationId, target_id: NationId, troops: f32, border_tiles: Option<&HashSet<U16Vec2>>, territory_manager: &TerritoryManager, terrain: &crate::game::terrain::TerrainData, player_borders: &HashMap<NationId, &HashSet<U16Vec2>>, turn_number: u64, rng: &DeterministicRng) {
|
||||
let attack_id = self.next_attack_id;
|
||||
self.next_attack_id += 1;
|
||||
|
||||
let attack = AttackExecutor::new(AttackConfig { attack_id, player_id, target_id: Some(target_id), troops, border_tiles, territory_manager, player_borders, turn_number, terrain }, rng);
|
||||
|
||||
let attack_key = self.attacks.insert(attack);
|
||||
self.index.add_player_attack(player_id, target_id, attack_key);
|
||||
}
|
||||
|
||||
/// Get all attacks involving a specific player (as attacker or target)
|
||||
///
|
||||
/// Returns a list of (attacker_id, target_id, troops, start_turn, is_outgoing)
|
||||
/// sorted by start_turn descending (most recent first)
|
||||
pub fn get_attacks_for_player(&self, player_id: NationId) -> Vec<(NationId, Option<NationId>, f32, u64, bool)> {
|
||||
let mut attacks = Vec::new();
|
||||
|
||||
// Add outgoing attacks (player is attacker)
|
||||
if let Some(attack_set) = self.index.get_attacks_by_player(player_id) {
|
||||
for &attack_key in attack_set {
|
||||
let attack = &self.attacks[attack_key];
|
||||
attacks.push((
|
||||
attack.player_id,
|
||||
attack.target_id,
|
||||
attack.get_troops(),
|
||||
attack.id(),
|
||||
true, // outgoing
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Add incoming attacks (player is target)
|
||||
if let Some(attack_set) = self.index.get_attacks_on_player(player_id) {
|
||||
for &attack_key in attack_set {
|
||||
let attack = &self.attacks[attack_key];
|
||||
attacks.push((
|
||||
attack.player_id,
|
||||
attack.target_id,
|
||||
attack.get_troops(),
|
||||
attack.id(),
|
||||
false, // incoming
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by attack ID descending (most recent first)
|
||||
attacks.sort_by(|a, b| b.3.cmp(&a.3));
|
||||
attacks
|
||||
}
|
||||
|
||||
/// Remove an attack and update all indices
|
||||
///
|
||||
/// With SlotMap, keys remain stable so no index shifting is needed.
|
||||
/// HashSet provides O(1) removal without element shifting.
|
||||
fn remove_attack(&mut self, attack_key: AttackKey) {
|
||||
let attack = &self.attacks[attack_key];
|
||||
let player_id = attack.player_id;
|
||||
let target_id = attack.target_id;
|
||||
|
||||
// Remove from all indices atomically
|
||||
if let Some(target_id) = target_id {
|
||||
self.index.remove_player_attack(player_id, target_id, attack_key);
|
||||
} else {
|
||||
self.index.remove_unclaimed_attack(player_id, attack_key);
|
||||
}
|
||||
|
||||
// Remove attack from slot map - no index shifting needed!
|
||||
self.attacks.remove(attack_key);
|
||||
}
|
||||
}
|
||||
145
crates/borders-core/src/game/combat/calculator.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
/// Pure combat calculation functions
|
||||
///
|
||||
/// This module contains all combat mathematics extracted from the attack system.
|
||||
/// All functions are pure (no side effects) and deterministic, making them
|
||||
/// easy to test, reason about, and modify.
|
||||
use glam::U16Vec2;
|
||||
|
||||
use crate::game::core::constants::combat::*;
|
||||
use crate::game::world::TerritoryManager;
|
||||
|
||||
/// Parameters for combat result calculation
|
||||
pub struct CombatParams<'a> {
|
||||
pub attacker_troops: f32,
|
||||
pub attacker_territory_size: usize,
|
||||
pub defender_troops: Option<f32>,
|
||||
pub defender_territory_size: Option<usize>,
|
||||
pub tile: U16Vec2,
|
||||
pub territory_manager: &'a TerritoryManager,
|
||||
pub width: u16,
|
||||
}
|
||||
|
||||
/// Result of combat calculations for conquering one tile
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CombatResult {
|
||||
/// Troops lost by the attacker
|
||||
pub attacker_loss: f32,
|
||||
/// Troops lost by the defender
|
||||
pub defender_loss: f32,
|
||||
/// How much of the "tiles per tick" budget this conquest consumes
|
||||
pub tiles_per_tick_used: f32,
|
||||
}
|
||||
|
||||
/// Sigmoid function for smooth scaling curves
|
||||
///
|
||||
/// Used for empire size balancing to create smooth transitions
|
||||
/// rather than hard thresholds.
|
||||
#[inline]
|
||||
pub fn sigmoid(x: f32, decay_rate: f32, midpoint: f32) -> f32 {
|
||||
1.0 / (1.0 + (-(x - midpoint) * decay_rate).exp())
|
||||
}
|
||||
|
||||
/// Calculate combat result for conquering one tile
|
||||
///
|
||||
/// This function determines troop losses and conquest cost based on:
|
||||
/// - Attacker and defender troop counts and empire sizes
|
||||
/// - Terrain properties (currently plains baseline)
|
||||
/// - Empire size balancing (prevents snowballing)
|
||||
/// - Defense structures (placeholder for future implementation)
|
||||
pub fn calculate_combat_result(params: CombatParams) -> CombatResult {
|
||||
if let (Some(defender_troops), Some(defender_territory_size)) = (params.defender_troops, params.defender_territory_size) {
|
||||
// Attacking claimed territory
|
||||
|
||||
// Base terrain values (plains baseline)
|
||||
let mut mag = BASE_MAG_PLAINS;
|
||||
let mut speed = BASE_SPEED_PLAINS;
|
||||
|
||||
// Defense post check (placeholder - always false for now)
|
||||
let has_defense_post = check_defense_post_nearby(params.tile, params.territory_manager);
|
||||
if has_defense_post {
|
||||
mag *= DEFENSE_POST_MAG_MULTIPLIER;
|
||||
speed *= DEFENSE_POST_SPEED_MULTIPLIER;
|
||||
}
|
||||
|
||||
// Empire size balancing - prevents snowballing
|
||||
// Large defenders get debuffed, large attackers get penalized
|
||||
let defense_sig = 1.0 - sigmoid(defender_territory_size as f32, DEFENSE_DEBUFF_DECAY_RATE, DEFENSE_DEBUFF_MIDPOINT);
|
||||
let large_defender_speed_debuff = LARGE_DEFENDER_BASE_DEBUFF + LARGE_DEFENDER_SCALING * defense_sig;
|
||||
let large_defender_attack_debuff = LARGE_DEFENDER_BASE_DEBUFF + LARGE_DEFENDER_SCALING * defense_sig;
|
||||
|
||||
let large_attacker_bonus = if params.attacker_territory_size > LARGE_EMPIRE_THRESHOLD as usize { (LARGE_EMPIRE_THRESHOLD as f32 / params.attacker_territory_size as f32).sqrt().powf(LARGE_ATTACKER_POWER_EXPONENT) } else { 1.0 };
|
||||
|
||||
let large_attacker_speed_bonus = if params.attacker_territory_size > LARGE_EMPIRE_THRESHOLD as usize { (LARGE_EMPIRE_THRESHOLD as f32 / params.attacker_territory_size as f32).powf(LARGE_ATTACKER_SPEED_EXPONENT) } else { 1.0 };
|
||||
|
||||
// Calculate troop ratio
|
||||
let troop_ratio = (defender_troops / params.attacker_troops.max(1.0)).clamp(TROOP_RATIO_MIN, TROOP_RATIO_MAX);
|
||||
|
||||
// Final attacker loss
|
||||
let attacker_loss = troop_ratio * mag * ATTACKER_LOSS_MULTIPLIER * large_defender_attack_debuff * large_attacker_bonus;
|
||||
|
||||
// Defender loss (simple: troops per tile)
|
||||
let defender_loss = defender_troops / defender_territory_size.max(1) as f32;
|
||||
|
||||
// Tiles per tick cost for this tile
|
||||
let tiles_per_tick_used = (defender_troops / (TILES_PER_TICK_DIVISOR * params.attacker_troops.max(1.0))).clamp(TILES_PER_TICK_MIN, TILES_PER_TICK_MAX) * speed * large_defender_speed_debuff * large_attacker_speed_bonus;
|
||||
|
||||
CombatResult { attacker_loss, defender_loss, tiles_per_tick_used }
|
||||
} else {
|
||||
// Attacking unclaimed territory
|
||||
CombatResult { attacker_loss: BASE_MAG_PLAINS / UNCLAIMED_ATTACK_LOSS_DIVISOR, defender_loss: 0.0, tiles_per_tick_used: ((UNCLAIMED_BASE_MULTIPLIER * BASE_SPEED_PLAINS.max(MIN_SPEED_PLAINS)) / params.attacker_troops.max(1.0)).clamp(UNCLAIMED_TILES_MIN, UNCLAIMED_TILES_MAX) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate tiles conquered per tick based on troop ratio and border size
|
||||
///
|
||||
/// This determines how fast an attack progresses. It's based on:
|
||||
/// - The attacker's troop advantage (or disadvantage)
|
||||
/// - The size of the attack border
|
||||
/// - Random variation for organic-looking expansion
|
||||
pub fn calculate_tiles_per_tick(attacker_troops: f32, defender_troops: Option<f32>, border_size: f32) -> f32 {
|
||||
if let Some(defender_troops) = defender_troops {
|
||||
// Dynamic based on troop ratio
|
||||
let ratio = ((ATTACK_RATIO_MULTIPLIER * attacker_troops) / defender_troops.max(1.0)) * ATTACK_RATIO_SCALE;
|
||||
let clamped_ratio = ratio.clamp(ATTACK_RATIO_MIN, ATTACK_RATIO_MAX);
|
||||
clamped_ratio * border_size * CLAIMED_TILES_PER_TICK_MULTIPLIER
|
||||
} else {
|
||||
// Fixed rate for unclaimed territory
|
||||
border_size * UNCLAIMED_TILES_PER_TICK_MULTIPLIER
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if defender has a defense post nearby (placeholder)
|
||||
///
|
||||
/// This will be implemented when defense structures are added to the game.
|
||||
/// For now, always returns false.
|
||||
fn check_defense_post_nearby(_tile: U16Vec2, _territory_manager: &TerritoryManager) -> bool {
|
||||
// Placeholder for future defense post implementation
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sigmoid_midpoint() {
|
||||
let result = sigmoid(DEFENSE_DEBUFF_MIDPOINT, DEFENSE_DEBUFF_DECAY_RATE, DEFENSE_DEBUFF_MIDPOINT);
|
||||
assert!((result - 0.5).abs() < 0.01, "Sigmoid should be ~0.5 at midpoint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clamp() {
|
||||
assert_eq!(5.0_f32.clamp(0.0, 10.0), 5.0);
|
||||
assert_eq!((-1.0_f32).clamp(0.0, 10.0), 0.0);
|
||||
assert_eq!(15.0_f32.clamp(0.0, 10.0), 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unclaimed_attack_fixed_losses() {
|
||||
// Unclaimed territory should have fixed attacker loss
|
||||
let result = CombatResult { attacker_loss: BASE_MAG_PLAINS / 5.0, defender_loss: 0.0, tiles_per_tick_used: 10.0 };
|
||||
|
||||
assert_eq!(result.attacker_loss, 16.0);
|
||||
assert_eq!(result.defender_loss, 0.0);
|
||||
}
|
||||
}
|
||||
395
crates/borders-core/src/game/combat/executor.rs
Normal file
@@ -0,0 +1,395 @@
|
||||
/// Attack execution logic
|
||||
///
|
||||
/// This module contains the `AttackExecutor` which manages the progression
|
||||
/// of a single attack over multiple turns. It handles tile prioritization,
|
||||
/// border expansion, and conquest mechanics.
|
||||
use std::collections::{BinaryHeap, HashMap, HashSet};
|
||||
|
||||
use glam::U16Vec2;
|
||||
use rand::Rng;
|
||||
|
||||
use super::calculator::{CombatParams, calculate_combat_result, calculate_tiles_per_tick};
|
||||
use crate::game::core::constants::combat::*;
|
||||
use crate::game::core::rng::DeterministicRng;
|
||||
use crate::game::core::utils::neighbors;
|
||||
use crate::game::entities::{PlayerEntityMap, TerritorySize, Troops};
|
||||
use crate::game::world::{NationId, TerritoryManager};
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
/// Priority queue entry for tile conquest
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct TilePriority {
|
||||
tile: U16Vec2,
|
||||
priority: i64, // Lower value = higher priority (conquered sooner)
|
||||
}
|
||||
|
||||
impl PartialOrd for TilePriority {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for TilePriority {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
other.priority.cmp(&self.priority).then_with(|| self.tile.x.cmp(&other.tile.x).then_with(|| self.tile.y.cmp(&other.tile.y)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for creating an AttackExecutor
|
||||
pub struct AttackConfig<'a> {
|
||||
pub attack_id: u64,
|
||||
pub player_id: NationId,
|
||||
pub target_id: Option<NationId>,
|
||||
pub troops: f32,
|
||||
pub border_tiles: Option<&'a HashSet<U16Vec2>>,
|
||||
pub territory_manager: &'a TerritoryManager,
|
||||
pub player_borders: &'a HashMap<NationId, &'a HashSet<U16Vec2>>,
|
||||
pub turn_number: u64,
|
||||
pub terrain: &'a crate::game::terrain::TerrainData,
|
||||
}
|
||||
|
||||
/// Executes a single ongoing attack (conquering tiles over time)
|
||||
///
|
||||
/// An attack progresses over multiple turns, conquering tiles based on:
|
||||
/// - Available troops
|
||||
/// - Troop ratio vs defender
|
||||
/// - Border size and connectivity
|
||||
/// - Combat formulas from the calculator module
|
||||
///
|
||||
/// The executor maintains a priority queue of tiles to conquer and updates
|
||||
/// borders as it progresses.
|
||||
pub struct AttackExecutor {
|
||||
id: u64,
|
||||
pub player_id: NationId,
|
||||
pub target_id: Option<NationId>,
|
||||
troops: f32,
|
||||
/// Active conquest frontier - tiles being evaluated/conquered by this attack.
|
||||
/// Distinct from player BorderTiles: dynamically shrinks as tiles are conquered
|
||||
/// and expands as new neighbors become targets.
|
||||
conquest_frontier: HashSet<U16Vec2>,
|
||||
priority_queue: BinaryHeap<TilePriority>,
|
||||
start_turn: u64,
|
||||
current_turn: u64,
|
||||
tiles_conquered: usize, // Counter for each tile conquered (for priority calculation)
|
||||
pending_removal: bool, // Mark attack for removal on next tick (allows final troops=0 update)
|
||||
}
|
||||
|
||||
impl AttackExecutor {
|
||||
/// Create a new attack executor
|
||||
pub fn new(config: AttackConfig, rng: &DeterministicRng) -> Self {
|
||||
let mut executor = Self { id: config.attack_id, player_id: config.player_id, target_id: config.target_id, troops: config.troops, conquest_frontier: HashSet::new(), priority_queue: BinaryHeap::new(), start_turn: config.turn_number, current_turn: config.turn_number, tiles_conquered: 0, pending_removal: false };
|
||||
|
||||
executor.initialize_border(config.border_tiles, config.territory_manager, config.terrain, config.player_borders, rng);
|
||||
|
||||
executor
|
||||
}
|
||||
|
||||
/// Modify the amount of troops in the attack
|
||||
pub fn modify_troops(&mut self, amount: f32) {
|
||||
self.troops += amount;
|
||||
}
|
||||
|
||||
/// Add new border tiles to the attack, allowing expansion from multiple fronts
|
||||
///
|
||||
/// This enables multi-region expansion when attacking the same target from different areas
|
||||
pub fn add_borders(&mut self, new_border_tiles: &HashSet<U16Vec2>, territory_manager: &TerritoryManager, terrain: &crate::game::terrain::TerrainData, rng: &DeterministicRng) {
|
||||
// Add neighbors from each new border tile
|
||||
for &tile in new_border_tiles {
|
||||
for neighbor in neighbors(tile, territory_manager.size()) {
|
||||
if self.is_valid_target(neighbor, territory_manager, terrain) && !self.conquest_frontier.contains(&neighbor) {
|
||||
self.add_tile_to_border(neighbor, territory_manager, rng);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Oppose an attack (counter-attack)
|
||||
///
|
||||
/// Returns true if the attack continues, false if it was defeated
|
||||
pub fn oppose(&mut self, troop_count: f32) -> bool {
|
||||
if self.troops > troop_count {
|
||||
self.troops -= troop_count;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the unique attack identifier
|
||||
pub fn id(&self) -> u64 {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// Get the amount of troops in the attack
|
||||
pub fn get_troops(&self) -> f32 {
|
||||
self.troops.max(0.0).floor()
|
||||
}
|
||||
|
||||
/// Get the turn this attack started
|
||||
pub fn get_start_turn(&self) -> u64 {
|
||||
self.start_turn
|
||||
}
|
||||
|
||||
/// Tick the attack executor
|
||||
///
|
||||
/// Returns true if the attack continues, false if it's finished
|
||||
pub fn tick(&mut self, entity_map: &PlayerEntityMap, players: &mut Query<(&mut Troops, &mut TerritorySize)>, territory_manager: &mut TerritoryManager, terrain: &crate::game::terrain::TerrainData, player_borders: &HashMap<NationId, &HashSet<U16Vec2>>, rng: &DeterministicRng) -> bool {
|
||||
let _guard = tracing::trace_span!("attack_tick", player_id = %self.player_id).entered();
|
||||
|
||||
// If marked for removal, remove now (allows one final update with troops=0)
|
||||
if self.pending_removal {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.current_turn += 1;
|
||||
|
||||
// Calculate how many tiles to conquer this tick
|
||||
let mut tiles_per_tick = self.calculate_tiles_per_tick(entity_map, players, rng);
|
||||
|
||||
// Track if we've already refreshed this tick to prevent infinite refresh loops
|
||||
let mut has_refreshed = false;
|
||||
|
||||
// Process tiles from priority queue
|
||||
while tiles_per_tick > 0.0 {
|
||||
if self.troops < 1.0 {
|
||||
self.troops = 0.0;
|
||||
self.pending_removal = true;
|
||||
return true; // Keep alive for one more tick to send troops=0
|
||||
}
|
||||
|
||||
if self.priority_queue.is_empty() {
|
||||
// If we already refreshed this tick, stop to prevent infinite loop
|
||||
if has_refreshed {
|
||||
self.troops = 0.0;
|
||||
self.pending_removal = true;
|
||||
return true; // Keep alive for one more tick to send troops=0
|
||||
}
|
||||
|
||||
// Remember border size before refresh
|
||||
let border_size_before = self.conquest_frontier.len();
|
||||
|
||||
// Refresh border tiles one last time before giving up
|
||||
self.refresh_border(player_borders, territory_manager, terrain, rng);
|
||||
has_refreshed = true;
|
||||
|
||||
// If refresh found no new tiles, attack is finished
|
||||
if self.conquest_frontier.len() == border_size_before {
|
||||
self.troops = 0.0;
|
||||
self.pending_removal = true;
|
||||
return true; // Keep alive for one more tick to send troops=0
|
||||
}
|
||||
|
||||
// If still empty after refresh (all tiles invalid), attack is finished
|
||||
if self.priority_queue.is_empty() {
|
||||
self.troops = 0.0;
|
||||
self.pending_removal = true;
|
||||
return true; // Keep alive for one more tick to send troops=0
|
||||
}
|
||||
}
|
||||
|
||||
let tile_priority = self.priority_queue.pop().unwrap();
|
||||
let tile = tile_priority.tile;
|
||||
self.conquest_frontier.remove(&tile);
|
||||
|
||||
// Check connectivity and validity
|
||||
let on_border = Self::check_borders_tile(tile, self.player_id, territory_manager);
|
||||
let tile_valid = self.is_valid_target(tile, territory_manager, terrain);
|
||||
|
||||
// Prevent attacking own tiles (race condition during conquest)
|
||||
let tile_owner = territory_manager.get_ownership(tile);
|
||||
let attacking_self = tile_owner.nation_id() == Some(self.player_id);
|
||||
|
||||
// Skip if any check fails
|
||||
if !tile_valid || !on_border || attacking_self {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add neighbors BEFORE conquering (critical for correct expansion)
|
||||
self.add_neighbors_to_border(tile, territory_manager, terrain, rng);
|
||||
|
||||
// Query attacker territory size from ECS
|
||||
let attacker_troops = self.troops;
|
||||
let attacker_territory_size = if let Some(&attacker_entity) = entity_map.0.get(&self.player_id)
|
||||
&& let Ok((_, territory)) = players.get(attacker_entity)
|
||||
{
|
||||
territory.0
|
||||
} else {
|
||||
// Attacker no longer exists - immediate removal (error state)
|
||||
return false;
|
||||
};
|
||||
|
||||
// Query defender stats from ECS if attacking a player
|
||||
let (defender_troops, defender_territory_size) = if let Some(target_id) = self.target_id { if let Some(&defender_entity) = entity_map.0.get(&target_id) { if let Ok((troops, territory)) = players.get(defender_entity) { (Some(troops.0), Some(territory.0)) } else { (None, None) } } else { (None, None) } } else { (None, None) };
|
||||
|
||||
// Calculate losses for this tile
|
||||
let combat_result = { calculate_combat_result(CombatParams { attacker_troops, attacker_territory_size: attacker_territory_size as usize, defender_troops, defender_territory_size: defender_territory_size.map(|s| s as usize), tile, territory_manager, width: territory_manager.width() }) };
|
||||
|
||||
// Check if we still have enough troops to conquer this tile
|
||||
if self.troops < combat_result.attacker_loss {
|
||||
self.troops = 0.0;
|
||||
self.pending_removal = true;
|
||||
return true; // Keep alive for one more tick to send troops=0
|
||||
}
|
||||
|
||||
// Apply troop losses
|
||||
self.troops -= combat_result.attacker_loss;
|
||||
if let Some(target_id) = self.target_id
|
||||
&& let Some(&defender_entity) = entity_map.0.get(&target_id)
|
||||
&& let Ok((mut troops, _)) = players.get_mut(defender_entity)
|
||||
{
|
||||
troops.0 = (troops.0 - combat_result.defender_loss).max(0.0);
|
||||
}
|
||||
|
||||
// Conquer the tile
|
||||
let previous_owner = territory_manager.conquer(tile, self.player_id);
|
||||
|
||||
// Update player territory sizes
|
||||
if let Some(nation_id) = previous_owner
|
||||
&& let Some(&entity) = entity_map.0.get(&nation_id)
|
||||
&& let Ok((_, mut territory_size)) = players.get_mut(entity)
|
||||
{
|
||||
territory_size.0 = territory_size.0.saturating_sub(1);
|
||||
}
|
||||
if let Some(&entity) = entity_map.0.get(&self.player_id)
|
||||
&& let Ok((_, mut territory_size)) = players.get_mut(entity)
|
||||
{
|
||||
territory_size.0 += 1;
|
||||
}
|
||||
|
||||
// Increment tiles conquered counter (used for priority calculation)
|
||||
self.tiles_conquered += 1;
|
||||
|
||||
// Decrement tiles per tick counter
|
||||
tiles_per_tick -= combat_result.tiles_per_tick_used;
|
||||
}
|
||||
|
||||
// Check if attack should continue
|
||||
!self.priority_queue.is_empty() && self.troops >= 1.0
|
||||
}
|
||||
|
||||
/// Calculate tiles conquered per tick based on troop ratio and border size
|
||||
fn calculate_tiles_per_tick(&mut self, entity_map: &PlayerEntityMap, players: &Query<(&mut Troops, &mut TerritorySize)>, rng: &DeterministicRng) -> f32 {
|
||||
// Add random 0-4 to border size
|
||||
// This introduces natural variation in expansion speed
|
||||
let mut context_rng = rng.for_context(self.player_id.get() as u64);
|
||||
let random_border_adjustment = context_rng.random_range(0..BORDER_RANDOM_ADJUSTMENT_MAX) as f32;
|
||||
let border_size = self.priority_queue.len() as f32 + random_border_adjustment;
|
||||
|
||||
// Query defender troops if attacking a player
|
||||
let defender_troops = if let Some(target_id) = self.target_id { entity_map.0.get(&target_id).and_then(|&entity| players.get(entity).ok()).map(|(troops, _)| troops.0) } else { None };
|
||||
|
||||
calculate_tiles_per_tick(self.troops, defender_troops, border_size)
|
||||
}
|
||||
|
||||
/// Check if a tile is a valid target for this attack
|
||||
fn is_valid_target(&self, tile: U16Vec2, territory_manager: &TerritoryManager, terrain: &crate::game::terrain::TerrainData) -> bool {
|
||||
if let Some(target_id) = self.target_id {
|
||||
territory_manager.is_owner(tile, target_id)
|
||||
} else {
|
||||
// For unclaimed attacks, check if tile is unowned and conquerable (not water)
|
||||
!territory_manager.has_owner(tile) && terrain.is_conquerable(tile)
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a tile to the border with proper priority calculation
|
||||
fn add_tile_to_border(&mut self, tile: U16Vec2, territory_manager: &TerritoryManager, rng: &DeterministicRng) {
|
||||
self.conquest_frontier.insert(tile);
|
||||
let priority = self.calculate_tile_priority(tile, territory_manager, rng);
|
||||
self.priority_queue.push(TilePriority { tile, priority });
|
||||
}
|
||||
|
||||
/// Initialize border tiles from player's existing borders
|
||||
fn initialize_border(&mut self, border_tiles: Option<&HashSet<U16Vec2>>, territory_manager: &TerritoryManager, terrain: &crate::game::terrain::TerrainData, player_borders: &HashMap<NationId, &HashSet<U16Vec2>>, rng: &DeterministicRng) {
|
||||
self.initialize_border_internal(border_tiles, territory_manager, terrain, player_borders, rng, false);
|
||||
}
|
||||
|
||||
/// Refresh the attack border by re-scanning all player border tiles
|
||||
///
|
||||
/// This gives the attack one last chance to find conquerable tiles before ending
|
||||
fn refresh_border(&mut self, player_borders: &HashMap<NationId, &HashSet<U16Vec2>>, territory_manager: &TerritoryManager, terrain: &crate::game::terrain::TerrainData, rng: &DeterministicRng) {
|
||||
self.initialize_border_internal(None, territory_manager, terrain, player_borders, rng, true);
|
||||
}
|
||||
|
||||
/// Internal method to initialize or refresh border tiles
|
||||
fn initialize_border_internal(&mut self, border_tiles: Option<&HashSet<U16Vec2>>, territory_manager: &TerritoryManager, terrain: &crate::game::terrain::TerrainData, player_borders: &HashMap<NationId, &HashSet<U16Vec2>>, rng: &DeterministicRng, clear_first: bool) {
|
||||
if clear_first {
|
||||
self.priority_queue.clear();
|
||||
self.conquest_frontier.clear();
|
||||
}
|
||||
|
||||
// Get borders or use empty set as fallback (needs lifetime handling)
|
||||
let empty_borders = HashSet::new();
|
||||
let borders = border_tiles.or_else(|| player_borders.get(&self.player_id).copied()).unwrap_or(&empty_borders);
|
||||
|
||||
let border_count = borders.len();
|
||||
|
||||
let _refresh_guard;
|
||||
let _init_guard;
|
||||
if clear_first {
|
||||
_refresh_guard = tracing::trace_span!("refresh_attack_border", border_count).entered();
|
||||
} else {
|
||||
_init_guard = tracing::trace_span!("initialize_attack_border", border_count).entered();
|
||||
}
|
||||
|
||||
// Find all target tiles adjacent to our borders
|
||||
for &tile in borders {
|
||||
for neighbor in neighbors(tile, territory_manager.size()) {
|
||||
if self.is_valid_target(neighbor, territory_manager, terrain) && !self.conquest_frontier.contains(&neighbor) {
|
||||
self.add_tile_to_border(neighbor, territory_manager, rng);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add neighbors of a newly conquered tile to the border
|
||||
fn add_neighbors_to_border(&mut self, tile: U16Vec2, territory_manager: &TerritoryManager, terrain: &crate::game::terrain::TerrainData, rng: &DeterministicRng) {
|
||||
for neighbor in neighbors(tile, territory_manager.size()) {
|
||||
if self.is_valid_target(neighbor, territory_manager, terrain) && !self.conquest_frontier.contains(&neighbor) {
|
||||
self.add_tile_to_border(neighbor, territory_manager, rng);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate priority for a tile (lower = conquered sooner)
|
||||
///
|
||||
/// Uses tiles_conquered counter to ensure wave-like expansion
|
||||
fn calculate_tile_priority(&self, tile: U16Vec2, territory_manager: &TerritoryManager, rng: &DeterministicRng) -> i64 {
|
||||
// Count how many neighbors are owned by attacker
|
||||
let num_owned_by_attacker = neighbors(tile, territory_manager.size()).filter(|&neighbor| territory_manager.is_owner(neighbor, self.player_id)).count();
|
||||
|
||||
let terrain_mag = 1.0;
|
||||
|
||||
// Random factor (0-7)
|
||||
let mut tile_rng = rng.for_tile(tile);
|
||||
let random_factor = tile_rng.random_range(0..TILE_PRIORITY_RANDOM_MAX);
|
||||
|
||||
// Priority calculation (lower = higher priority, conquered sooner)
|
||||
// Base calculation: tiles surrounded by more attacker neighbors get LOWER modifier values
|
||||
// Adding tiles_conquered ensures tiles discovered earlier get lower priority values
|
||||
// This creates wave-like expansion: older tiles (lower priority) conquered before newer tiles (higher priority)
|
||||
let base = (random_factor + 10) as f32;
|
||||
let modifier = TILE_PRIORITY_BASE - (num_owned_by_attacker as f32 * TILE_PRIORITY_NEIGHBOR_PENALTY) + (terrain_mag / 2.0);
|
||||
(base * modifier) as i64 + self.tiles_conquered as i64
|
||||
}
|
||||
|
||||
/// Handle the addition of a tile to the player's territory
|
||||
pub fn handle_player_tile_add(&mut self, tile: U16Vec2, territory_manager: &TerritoryManager, terrain: &crate::game::terrain::TerrainData, rng: &DeterministicRng) {
|
||||
// When player gains a tile, check its neighbors for new targets
|
||||
self.add_neighbors_to_border(tile, territory_manager, terrain, rng);
|
||||
}
|
||||
|
||||
/// Handle the addition of a tile to the target's territory
|
||||
pub fn handle_target_tile_add(&mut self, tile: U16Vec2, territory_manager: &TerritoryManager, rng: &DeterministicRng) {
|
||||
// If target gains a tile that borders our territory, add it to attack
|
||||
if Self::check_borders_tile(tile, self.player_id, territory_manager) && !self.conquest_frontier.contains(&tile) {
|
||||
self.conquest_frontier.insert(tile);
|
||||
let priority = self.calculate_tile_priority(tile, territory_manager, rng);
|
||||
self.priority_queue.push(TilePriority { tile, priority });
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a tile borders the player's territory
|
||||
fn check_borders_tile(tile: U16Vec2, player_id: NationId, territory_manager: &TerritoryManager) -> bool {
|
||||
neighbors(tile, territory_manager.size()).any(|neighbor| territory_manager.is_owner(neighbor, player_id))
|
||||
}
|
||||
}
|
||||
7
crates/borders-core/src/game/combat/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod active;
|
||||
pub mod calculator;
|
||||
pub mod executor;
|
||||
|
||||
pub use active::*;
|
||||
pub use calculator::*;
|
||||
pub use executor::*;
|
||||
49
crates/borders-core/src/game/core/action.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
//! Game action system
|
||||
//!
|
||||
//! This module defines the core action types that can be performed in the game.
|
||||
//! Actions represent discrete game events that can be initiated by both human players
|
||||
//! and AI bots. They are processed deterministically during turn execution.
|
||||
|
||||
use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::game::core::utils::u16vec2_serde;
|
||||
use crate::game::world::NationId;
|
||||
|
||||
/// Core game action type
|
||||
///
|
||||
/// This enum represents all possible actions that can be performed in the game.
|
||||
/// Unlike `Intent`, which is a network-layer wrapper, `GameAction` is the actual
|
||||
/// game-level operation.
|
||||
///
|
||||
/// Actions can originate from:
|
||||
/// - Human players (via input systems → intents → network → SourcedIntent wrapper)
|
||||
/// - AI bots (calculated deterministically during turn execution)
|
||||
///
|
||||
/// Player identity is provided separately:
|
||||
/// - For human players: wrapped in SourcedIntent by server (prevents spoofing)
|
||||
/// - For bots: generated with player_id in turn execution context
|
||||
///
|
||||
/// Note: Spawning is handled separately via Turn(0) and direct spawn manager updates,
|
||||
/// not through the action system.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Archive, RkyvSerialize, RkyvDeserialize)]
|
||||
#[rkyv(derive(Debug))]
|
||||
pub enum GameAction {
|
||||
/// Attack a target nation with a specified number of troops
|
||||
///
|
||||
/// The attack will proceed across all borders shared with the target:
|
||||
/// - `target: Some(nation_id)` - Attack specific nation across all shared borders
|
||||
/// - `target: None` - Expand into unclaimed territory from all borders
|
||||
Attack { target: Option<NationId>, troops: u32 },
|
||||
/// Launch a transport ship to attack across water
|
||||
LaunchShip {
|
||||
#[serde(with = "u16vec2_serde")]
|
||||
target_tile: glam::U16Vec2,
|
||||
troops: u32,
|
||||
},
|
||||
// Future action types:
|
||||
// BuildStructure { tile_index: U16Vec2, structure_type: StructureType },
|
||||
// LaunchNuke { target_tile: U16Vec2 },
|
||||
// RequestAlliance { target_player: NationId },
|
||||
// DeclareWar { target_player: NationId },
|
||||
}
|
||||
261
crates/borders-core/src/game/core/constants.rs
Normal file
@@ -0,0 +1,261 @@
|
||||
/// Game constants organized by domain
|
||||
///
|
||||
/// This module centralizes all game balance constants that were previously
|
||||
/// scattered across multiple files. Constants are grouped by gameplay domain
|
||||
/// for easy discovery and tuning.
|
||||
pub mod game {
|
||||
/// Game tick interval in milliseconds (10 TPS = 100ms per turn)
|
||||
pub const TICK_INTERVAL: u64 = 100;
|
||||
|
||||
/// Number of bot players
|
||||
pub const BOT_COUNT: usize = 500;
|
||||
}
|
||||
|
||||
pub mod combat {
|
||||
/// Empire size balancing - prevents snowballing by large empires
|
||||
/// Defense effectiveness decreases as empire grows beyond this threshold
|
||||
pub const DEFENSE_DEBUFF_MIDPOINT: f32 = 150_000.0;
|
||||
|
||||
/// Rate of defense effectiveness decay for large empires
|
||||
/// Uses natural log decay for smooth scaling
|
||||
pub const DEFENSE_DEBUFF_DECAY_RATE: f32 = std::f32::consts::LN_2 / 50_000.0;
|
||||
|
||||
/// Base terrain magnitude cost for plains (baseline terrain)
|
||||
/// Determines troop losses when conquering a tile
|
||||
pub const BASE_MAG_PLAINS: f32 = 80.0;
|
||||
|
||||
/// Base terrain speed for plains (baseline terrain)
|
||||
/// Affects how many tiles can be conquered per tick
|
||||
pub const BASE_SPEED_PLAINS: f32 = 16.5;
|
||||
|
||||
/// Maximum random adjustment to border size when calculating expansion speed
|
||||
/// Introduces natural variation in attack progression (0-4 range)
|
||||
pub const BORDER_RANDOM_ADJUSTMENT_MAX: u32 = 5;
|
||||
|
||||
/// Multiplier for tiles conquered per tick when attacking unclaimed territory
|
||||
pub const UNCLAIMED_TILES_PER_TICK_MULTIPLIER: f32 = 2.0;
|
||||
|
||||
/// Multiplier for tiles conquered per tick when attacking claimed territory
|
||||
pub const CLAIMED_TILES_PER_TICK_MULTIPLIER: f32 = 3.0;
|
||||
|
||||
/// Large empire threshold for attack penalties (>100k tiles)
|
||||
pub const LARGE_EMPIRE_THRESHOLD: u32 = 100_000;
|
||||
|
||||
/// Random factor range for tile priority calculation (0-7)
|
||||
pub const TILE_PRIORITY_RANDOM_MAX: u32 = 8;
|
||||
|
||||
/// Defense post magnitude multiplier (when implemented)
|
||||
pub const DEFENSE_POST_MAG_MULTIPLIER: f32 = 5.0;
|
||||
|
||||
/// Defense post speed multiplier (when implemented)
|
||||
pub const DEFENSE_POST_SPEED_MULTIPLIER: f32 = 3.0;
|
||||
|
||||
/// Base defense debuff for large defenders (70%)
|
||||
pub const LARGE_DEFENDER_BASE_DEBUFF: f32 = 0.7;
|
||||
|
||||
/// Scaling factor for large defender sigmoid (30%)
|
||||
pub const LARGE_DEFENDER_SCALING: f32 = 0.3;
|
||||
|
||||
/// Power exponent for large attacker bonus calculation
|
||||
pub const LARGE_ATTACKER_POWER_EXPONENT: f32 = 0.7;
|
||||
|
||||
/// Speed exponent for large attacker penalty calculation
|
||||
pub const LARGE_ATTACKER_SPEED_EXPONENT: f32 = 0.6;
|
||||
|
||||
/// Minimum troop ratio for combat calculations
|
||||
pub const TROOP_RATIO_MIN: f32 = 0.6;
|
||||
|
||||
/// Maximum troop ratio for combat calculations
|
||||
pub const TROOP_RATIO_MAX: f32 = 2.0;
|
||||
|
||||
/// Multiplier for attacker loss calculations
|
||||
pub const ATTACKER_LOSS_MULTIPLIER: f32 = 0.8;
|
||||
|
||||
/// Divisor for tiles per tick calculation
|
||||
pub const TILES_PER_TICK_DIVISOR: f32 = 5.0;
|
||||
|
||||
/// Minimum tiles per tick cost
|
||||
pub const TILES_PER_TICK_MIN: f32 = 0.2;
|
||||
|
||||
/// Maximum tiles per tick cost
|
||||
pub const TILES_PER_TICK_MAX: f32 = 1.5;
|
||||
|
||||
/// Divisor for unclaimed territory attack losses
|
||||
pub const UNCLAIMED_ATTACK_LOSS_DIVISOR: f32 = 5.0;
|
||||
|
||||
/// Base multiplier for unclaimed territory conquest speed
|
||||
pub const UNCLAIMED_BASE_MULTIPLIER: f32 = 2_000.0;
|
||||
|
||||
/// Minimum speed value for plains terrain
|
||||
pub const MIN_SPEED_PLAINS: f32 = 10.0;
|
||||
|
||||
/// Minimum tiles per tick for unclaimed territory
|
||||
pub const UNCLAIMED_TILES_MIN: f32 = 5.0;
|
||||
|
||||
/// Maximum tiles per tick for unclaimed territory
|
||||
pub const UNCLAIMED_TILES_MAX: f32 = 100.0;
|
||||
|
||||
/// Multiplier for attack ratio calculation
|
||||
pub const ATTACK_RATIO_MULTIPLIER: f32 = 5.0;
|
||||
|
||||
/// Scale factor for attack ratio
|
||||
pub const ATTACK_RATIO_SCALE: f32 = 2.0;
|
||||
|
||||
/// Minimum attack ratio for dynamic calculation
|
||||
pub const ATTACK_RATIO_MIN: f32 = 0.01;
|
||||
|
||||
/// Maximum attack ratio for dynamic calculation
|
||||
pub const ATTACK_RATIO_MAX: f32 = 0.5;
|
||||
|
||||
/// Base priority value for tile conquest
|
||||
pub const TILE_PRIORITY_BASE: f32 = 1.0;
|
||||
|
||||
/// Priority penalty per owned neighbor tile
|
||||
pub const TILE_PRIORITY_NEIGHBOR_PENALTY: f32 = 0.5;
|
||||
}
|
||||
|
||||
pub mod player {
|
||||
/// Multiplier for max troops calculation
|
||||
pub const MAX_TROOPS_MULTIPLIER: f32 = 2.0;
|
||||
|
||||
/// Power exponent for max troops based on territory size
|
||||
pub const MAX_TROOPS_POWER: f32 = 0.6;
|
||||
|
||||
/// Scale factor for max troops calculation
|
||||
pub const MAX_TROOPS_SCALE: f32 = 1000.0;
|
||||
|
||||
/// Base max troops value
|
||||
pub const MAX_TROOPS_BASE: f32 = 50_000.0;
|
||||
|
||||
/// Bots get 33% of human max troops
|
||||
pub const BOT_MAX_TROOPS_MULTIPLIER: f32 = 0.33;
|
||||
|
||||
/// Base income per tick
|
||||
pub const BASE_INCOME: f32 = 10.0;
|
||||
|
||||
/// Power exponent for income calculation
|
||||
pub const INCOME_POWER: f32 = 0.73;
|
||||
|
||||
/// Divisor for income calculation
|
||||
pub const INCOME_DIVISOR: f32 = 4.0;
|
||||
|
||||
/// Bots get 60% of human income
|
||||
pub const BOT_INCOME_MULTIPLIER: f32 = 0.6;
|
||||
|
||||
/// Initial troops for all players at spawn
|
||||
pub const INITIAL_TROOPS: f32 = 2500.0;
|
||||
}
|
||||
|
||||
pub mod bot {
|
||||
/// Maximum initial cooldown for bot actions (0-9 ticks)
|
||||
pub const INITIAL_COOLDOWN_MAX: u64 = 10;
|
||||
|
||||
/// Minimum cooldown between bot actions (ticks)
|
||||
pub const ACTION_COOLDOWN_MIN: u64 = 3;
|
||||
|
||||
/// Maximum cooldown between bot actions (ticks)
|
||||
pub const ACTION_COOLDOWN_MAX: u64 = 15;
|
||||
|
||||
/// Probability that bot chooses expansion over attack (60%)
|
||||
pub const EXPAND_PROBABILITY: f32 = 0.6;
|
||||
|
||||
/// Minimum troop percentage for wilderness expansion (10%)
|
||||
pub const EXPAND_TROOPS_MIN: f32 = 0.1;
|
||||
|
||||
/// Maximum troop percentage for wilderness expansion (30%)
|
||||
pub const EXPAND_TROOPS_MAX: f32 = 0.3;
|
||||
|
||||
/// Minimum troop percentage for player attacks (20%)
|
||||
pub const ATTACK_TROOPS_MIN: f32 = 0.2;
|
||||
|
||||
/// Maximum troop percentage for player attacks (50%)
|
||||
pub const ATTACK_TROOPS_MAX: f32 = 0.5;
|
||||
|
||||
/// Minimum distance between spawn points (in tiles)
|
||||
pub const MIN_SPAWN_DISTANCE: f32 = 70.0;
|
||||
|
||||
/// Absolute minimum spawn distance for fallback
|
||||
pub const ABSOLUTE_MIN_DISTANCE: f32 = 5.0;
|
||||
|
||||
/// Distance reduction factor per adaptive wave (15% reduction)
|
||||
pub const DISTANCE_REDUCTION_FACTOR: f32 = 0.85;
|
||||
|
||||
/// Number of random spawn placement attempts
|
||||
pub const SPAWN_RANDOM_ATTEMPTS: usize = 1000;
|
||||
|
||||
/// Maximum attempts for grid-guided spawn placement
|
||||
pub const SPAWN_GRID_MAX_ATTEMPTS: usize = 200;
|
||||
|
||||
/// Maximum attempts for fallback spawn placement
|
||||
pub const SPAWN_FALLBACK_ATTEMPTS: usize = 10_000;
|
||||
|
||||
/// Stride factor for grid-guided spawn placement (80% of current distance)
|
||||
pub const SPAWN_GRID_STRIDE_FACTOR: f32 = 0.8;
|
||||
|
||||
/// Maximum border tiles sampled for bot decision making
|
||||
pub const MAX_BORDER_SAMPLES: usize = 20;
|
||||
}
|
||||
|
||||
pub mod colors {
|
||||
/// Minimum hue value for color generation (degrees)
|
||||
pub const HUE_MIN: f32 = 0.0;
|
||||
|
||||
/// Maximum hue value for color generation (degrees)
|
||||
pub const HUE_MAX: f32 = 360.0;
|
||||
|
||||
/// Golden angle for visually distinct color distribution (degrees)
|
||||
pub const GOLDEN_ANGLE: f32 = 137.5;
|
||||
|
||||
/// Minimum saturation for player colors
|
||||
pub const SATURATION_MIN: f32 = 0.75;
|
||||
|
||||
/// Maximum saturation for player colors
|
||||
pub const SATURATION_MAX: f32 = 0.95;
|
||||
|
||||
/// Minimum lightness for player colors
|
||||
pub const LIGHTNESS_MIN: f32 = 0.35;
|
||||
|
||||
/// Maximum lightness for player colors
|
||||
pub const LIGHTNESS_MAX: f32 = 0.65;
|
||||
}
|
||||
|
||||
pub mod input {
|
||||
/// Default attack ratio when game starts (50%)
|
||||
pub const DEFAULT_ATTACK_RATIO: f32 = 0.5;
|
||||
|
||||
/// Step size for attack ratio adjustment (10%)
|
||||
pub const ATTACK_RATIO_STEP: f32 = 0.1;
|
||||
|
||||
/// Minimum attack ratio (10%)
|
||||
pub const ATTACK_RATIO_MIN: f32 = 0.1;
|
||||
|
||||
/// Maximum attack ratio (100%)
|
||||
pub const ATTACK_RATIO_MAX: f32 = 1.0;
|
||||
}
|
||||
|
||||
pub mod ships {
|
||||
/// Maximum ships per player
|
||||
pub const MAX_SHIPS_PER_PLAYER: usize = 5;
|
||||
|
||||
/// Ticks required to move one tile (1 = fast speed)
|
||||
pub const TICKS_PER_TILE: u32 = 1;
|
||||
|
||||
/// Maximum path length for ship pathfinding
|
||||
pub const MAX_PATH_LENGTH: usize = 1_000_000;
|
||||
|
||||
/// Percentage of troops carried by ship (20%)
|
||||
pub const TROOP_PERCENT: f32 = 0.20;
|
||||
}
|
||||
|
||||
pub mod outcome {
|
||||
/// Win threshold - percentage of map needed to win (80%)
|
||||
pub const WIN_THRESHOLD: f32 = 0.80;
|
||||
}
|
||||
|
||||
pub mod spawning {
|
||||
/// Radius of tiles claimed around spawn point (creates 5x5 square)
|
||||
pub const SPAWN_RADIUS: i16 = 2;
|
||||
|
||||
/// Spawn timeout duration in seconds
|
||||
pub const SPAWN_TIMEOUT_SECS: f32 = 2.0;
|
||||
}
|
||||
7
crates/borders-core/src/game/core/instance.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
/// Troop count specification for attacks
|
||||
pub enum TroopCount {
|
||||
/// Use a ratio of the player's current troops (0.0-1.0)
|
||||
Ratio(f32),
|
||||
/// Use an absolute troop count
|
||||
Absolute(u32),
|
||||
}
|
||||
200
crates/borders-core/src/game/core/lifecycle.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{Rng, SeedableRng};
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::game::ai::bot;
|
||||
use crate::game::core::constants::colors::*;
|
||||
use crate::game::core::constants::game::BOT_COUNT;
|
||||
use crate::game::core::constants::player::INITIAL_TROOPS;
|
||||
use crate::game::core::constants::spawning::SPAWN_TIMEOUT_SECS;
|
||||
use crate::game::core::rng::DeterministicRng;
|
||||
use crate::game::view::{GameView, PlayerView};
|
||||
use crate::game::{ActiveAttacks, CoastalTiles, HSLColor, LocalPlayerContext, SpawnManager, SpawnPhase, SpawnTimeout, TerritoryManager};
|
||||
use crate::game::{NationId, TerrainData};
|
||||
use crate::networking::server::{LocalTurnServerHandle, TurnGenerator, TurnReceiver};
|
||||
use flume::Receiver;
|
||||
|
||||
/// Parameters needed to initialize a new game
|
||||
pub struct GameInitParams {
|
||||
pub map_width: u16,
|
||||
pub map_height: u16,
|
||||
pub conquerable_tiles: Vec<bool>,
|
||||
pub client_player_id: NationId,
|
||||
pub intent_rx: Receiver<crate::networking::client::TrackedIntent>,
|
||||
pub terrain_data: Arc<crate::game::terrain::TerrainData>,
|
||||
}
|
||||
|
||||
/// Initialize all game resources when starting a new game
|
||||
/// This should be called by the StartGame command handler
|
||||
pub fn initialize_game_resources(commands: &mut Commands, params: GameInitParams) {
|
||||
let _guard = tracing::trace_span!("game_initialization", map_width = params.map_width, map_height = params.map_height).entered();
|
||||
|
||||
info!("Initializing game resources (map: {}x{}, player: {})", params.map_width, params.map_height, params.client_player_id.get());
|
||||
|
||||
// Initialize territory manager
|
||||
let mut territory_manager = TerritoryManager::new(params.map_width, params.map_height);
|
||||
territory_manager.reset(params.map_width, params.map_height, ¶ms.conquerable_tiles);
|
||||
debug!("Territory manager initialized with {} tiles", params.conquerable_tiles.len());
|
||||
|
||||
// Initialize active attacks
|
||||
let mut active_attacks = ActiveAttacks::new();
|
||||
active_attacks.init(1 + BOT_COUNT);
|
||||
|
||||
// Use a fixed seed for deterministic bot behavior and color generation
|
||||
// In multiplayer, this should come from the server
|
||||
let rng_seed = 0xDEADBEEF;
|
||||
|
||||
// Create RNG for deterministic color generation
|
||||
let mut rng = StdRng::seed_from_u64(rng_seed);
|
||||
|
||||
// Generate player metadata: 1 human + BOT_COUNT bots
|
||||
// Player IDs start at 0 (human), then 1, 2, 3... for bots
|
||||
let mut player_metadata = Vec::new();
|
||||
|
||||
// Generate random hue offset for color spread
|
||||
let hue_offset = rng.random_range(HUE_MIN..HUE_MAX);
|
||||
|
||||
// All players (including human) get deterministically generated colors
|
||||
for i in 0..=BOT_COUNT {
|
||||
let is_human = i == 0;
|
||||
let player_id = NationId::new(i as u16).expect("valid player ID");
|
||||
|
||||
// Use golden angle distribution with random offset for visually distinct colors
|
||||
let hue = (player_id.get() as f32 * GOLDEN_ANGLE + hue_offset) % HUE_MAX;
|
||||
let saturation = rng.random_range(SATURATION_MIN..=SATURATION_MAX);
|
||||
let lightness = rng.random_range(LIGHTNESS_MIN..=LIGHTNESS_MAX);
|
||||
let color = HSLColor::new(hue, saturation, lightness);
|
||||
|
||||
let name = if is_human { "Player".to_string() } else { format!("Bot {}", i) };
|
||||
|
||||
player_metadata.push((player_id, name, color));
|
||||
}
|
||||
|
||||
debug!("Player metadata generated for {} players (human: 0, bots: {})", 1 + BOT_COUNT, BOT_COUNT);
|
||||
|
||||
// Spawn player entities with ECS components first
|
||||
// This ensures entities exist from the start for update_player_borders_system
|
||||
|
||||
// Extract bot player IDs from metadata for spawn calculation
|
||||
let bot_player_ids: Vec<NationId> = player_metadata
|
||||
.iter()
|
||||
.skip(1) // Skip human player (index 0)
|
||||
.map(|(id, _, _)| *id)
|
||||
.collect();
|
||||
|
||||
// Calculate initial bot spawn positions (first pass)
|
||||
// These will be shown to the player, but not applied to game state yet
|
||||
let initial_bot_spawns = bot::calculate_initial_spawns(&bot_player_ids, &territory_manager, ¶ms.terrain_data, rng_seed);
|
||||
|
||||
debug!("Calculated {} initial bot spawn positions (requested: {})", initial_bot_spawns.len(), BOT_COUNT);
|
||||
|
||||
if initial_bot_spawns.len() < BOT_COUNT {
|
||||
tracing::warn!("Only {} of {} bots were able to spawn - map may be too small or bot count too high", initial_bot_spawns.len(), BOT_COUNT);
|
||||
}
|
||||
|
||||
// Create SpawnManager to track spawn positions during spawn phase
|
||||
let spawn_manager = SpawnManager::new(initial_bot_spawns.clone(), rng_seed);
|
||||
commands.insert_resource(spawn_manager);
|
||||
|
||||
// Count total land tiles from conquerable tiles data
|
||||
let total_land_tiles = params.conquerable_tiles.iter().filter(|&&is_land| is_land).count() as u32;
|
||||
|
||||
// Create entity map for O(1) player_id -> Entity lookups
|
||||
let mut entity_map = crate::game::PlayerEntityMap::default();
|
||||
|
||||
// Initial troops and territory for each player
|
||||
let initial_troops = INITIAL_TROOPS;
|
||||
let initial_territory_size = 0;
|
||||
|
||||
for (nation_id, name, color) in &player_metadata {
|
||||
let is_bot = bot_player_ids.contains(nation_id);
|
||||
|
||||
let entity = if is_bot { commands.spawn((crate::game::ai::bot::Bot::new(), *nation_id, crate::game::PlayerName(name.clone()), crate::game::PlayerColor(*color), crate::game::BorderTiles::default(), crate::game::Troops(initial_troops), crate::game::TerritorySize(initial_territory_size), crate::game::ships::ShipCount::default())).id() } else { commands.spawn((*nation_id, crate::game::PlayerName(name.clone()), crate::game::PlayerColor(*color), crate::game::BorderTiles::default(), crate::game::Troops(initial_troops), crate::game::TerritorySize(initial_territory_size), crate::game::ships::ShipCount::default())).id() };
|
||||
|
||||
entity_map.0.insert(*nation_id, entity);
|
||||
}
|
||||
|
||||
debug!("Player entities spawned with ECS components ({} total)", 1 + BOT_COUNT);
|
||||
|
||||
// Build initial GameView by reading from the ECS entities we just created
|
||||
let game_view = GameView {
|
||||
size: glam::U16Vec2::new(params.map_width, params.map_height),
|
||||
territories: Arc::from(territory_manager.as_slice()),
|
||||
turn_number: 0,
|
||||
total_land_tiles,
|
||||
changed_tiles: Vec::new(), // Empty on initialization
|
||||
players: player_metadata.iter().map(|(nation_id, name, color)| PlayerView { id: *nation_id, color: color.to_rgba(), name: name.clone(), tile_count: initial_territory_size, troops: initial_troops as u32, is_alive: true }).collect(),
|
||||
ships: Vec::new(), // No ships at initialization
|
||||
};
|
||||
|
||||
// Compute coastal tiles once
|
||||
let map_size = glam::U16Vec2::new(params.map_width, params.map_height);
|
||||
let coastal_tiles = CoastalTiles::compute(¶ms.terrain_data, map_size);
|
||||
debug!("Computed {} coastal tiles", coastal_tiles.len());
|
||||
|
||||
// Insert all individual game resources
|
||||
commands.insert_resource(entity_map);
|
||||
commands.insert_resource(crate::game::ClientPlayerId(params.client_player_id));
|
||||
commands.insert_resource(crate::game::HumanPlayerCount(1));
|
||||
commands.insert_resource(crate::game::ships::ShipIdCounter::new());
|
||||
commands.insert_resource(territory_manager);
|
||||
commands.insert_resource(active_attacks);
|
||||
commands.insert_resource(params.terrain_data.as_ref().clone());
|
||||
commands.insert_resource(DeterministicRng::new(rng_seed));
|
||||
commands.insert_resource(coastal_tiles);
|
||||
commands.insert_resource(game_view);
|
||||
|
||||
// Initialize local player context
|
||||
commands.insert_resource(LocalPlayerContext::new(NationId::ZERO));
|
||||
debug!("LocalPlayerContext created for player 0 (human)");
|
||||
|
||||
// Initialize spawn timeout
|
||||
commands.insert_resource(SpawnTimeout::new(SPAWN_TIMEOUT_SECS));
|
||||
debug!("SpawnTimeout initialized ({} seconds)", SPAWN_TIMEOUT_SECS);
|
||||
|
||||
// Initialize turn generation resources
|
||||
let (turn_tx, turn_rx) = flume::unbounded();
|
||||
let server_handle = LocalTurnServerHandle { paused: Arc::new(AtomicBool::new(true)), running: Arc::new(AtomicBool::new(true)) };
|
||||
commands.insert_resource(server_handle);
|
||||
commands.insert_resource(TurnReceiver { turn_rx });
|
||||
commands.insert_resource(TurnGenerator::new(turn_tx));
|
||||
|
||||
debug!("Turn generator initialized (paused until player spawn)");
|
||||
|
||||
// Activate spawn phase (SpawnPhasePlugin will emit initial SpawnPhaseUpdate)
|
||||
commands.insert_resource(SpawnPhase { active: true });
|
||||
debug!("Spawn phase activated");
|
||||
|
||||
info!("Game resources initialized successfully - ready to start");
|
||||
}
|
||||
|
||||
/// Clean up all game resources when quitting a game
|
||||
pub fn cleanup_game_resources(world: &mut World) {
|
||||
let _guard = tracing::trace_span!("game_cleanup").entered();
|
||||
info!("Cleaning up game resources...");
|
||||
|
||||
// Stop local turn server if running
|
||||
if let Some(server_handle) = world.get_resource::<LocalTurnServerHandle>() {
|
||||
server_handle.stop();
|
||||
world.remove_resource::<LocalTurnServerHandle>();
|
||||
}
|
||||
|
||||
// Remove all game-specific resources
|
||||
world.remove_resource::<TerritoryManager>();
|
||||
world.remove_resource::<ActiveAttacks>();
|
||||
world.remove_resource::<TerrainData>();
|
||||
world.remove_resource::<DeterministicRng>();
|
||||
world.remove_resource::<CoastalTiles>();
|
||||
world.remove_resource::<LocalPlayerContext>();
|
||||
world.remove_resource::<TurnReceiver>();
|
||||
world.remove_resource::<SpawnManager>();
|
||||
world.remove_resource::<SpawnTimeout>();
|
||||
world.remove_resource::<GameView>();
|
||||
world.remove_resource::<TurnGenerator>();
|
||||
|
||||
info!("Game resources cleaned up successfully");
|
||||
}
|
||||
22
crates/borders-core/src/game/core/mod.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
//! Core game logic and data structures
|
||||
//!
|
||||
//! This module contains the fundamental game types and logic.
|
||||
|
||||
pub mod action;
|
||||
pub mod constants;
|
||||
pub mod instance;
|
||||
pub mod lifecycle;
|
||||
pub mod outcome;
|
||||
pub mod rng;
|
||||
pub mod turn_execution;
|
||||
pub mod utils;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use action::*;
|
||||
pub use constants::*;
|
||||
pub use instance::*;
|
||||
pub use lifecycle::*;
|
||||
pub use outcome::*;
|
||||
pub use rng::*;
|
||||
pub use turn_execution::*;
|
||||
pub use utils::*;
|
||||
81
crates/borders-core/src/game/core/outcome.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use crate::game::core::constants::outcome::WIN_THRESHOLD;
|
||||
use crate::game::input::context::LocalPlayerContext;
|
||||
use crate::game::view::GameView;
|
||||
use crate::ui::protocol::{BackendMessage, GameOutcome};
|
||||
use bevy_ecs::prelude::*;
|
||||
use tracing::info;
|
||||
|
||||
/// System that checks if the local player has won or lost
|
||||
/// This is a NON-BLOCKING check - the game continues running regardless
|
||||
pub fn check_local_player_outcome(mut local_context: If<ResMut<LocalPlayerContext>>, game_view: If<Res<GameView>>, mut backend_messages: MessageWriter<BackendMessage>) {
|
||||
// Don't check if outcome already determined
|
||||
if local_context.my_outcome.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't check outcome until player has spawned
|
||||
// Skip only if player has 0 tiles AND is_alive (hasn't spawned yet)
|
||||
// If player has 0 tiles AND !is_alive, that's a real defeat
|
||||
let my_player_id = local_context.id;
|
||||
let Some(my_player) = game_view.get_nation_id(my_player_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if my_player.tile_count == 0 && my_player.is_alive {
|
||||
// Player hasn't spawned yet - skip outcome check
|
||||
return;
|
||||
}
|
||||
|
||||
// Check defeat condition: I've been eliminated (0 tiles)
|
||||
if !my_player.is_alive {
|
||||
info!("Local player defeated - eliminated (0 tiles)");
|
||||
local_context.mark_defeated();
|
||||
backend_messages.write(BackendMessage::GameEnded { outcome: GameOutcome::Defeat });
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate total claimable tiles for victory condition checks
|
||||
// Filter out unclaimed tiles
|
||||
let total_claimable_tiles = game_view.territories.iter().filter(|ownership| ownership.is_owned()).count();
|
||||
|
||||
if total_claimable_tiles > 0 {
|
||||
let my_tiles = my_player.tile_count as usize;
|
||||
let my_occupation = my_tiles as f32 / total_claimable_tiles as f32;
|
||||
|
||||
// Check if I've won by occupation
|
||||
if my_occupation >= WIN_THRESHOLD {
|
||||
info!("Local player victorious - reached {:.1}% occupation ({}/{} claimable tiles, threshold: {:.0}%)", my_occupation * 100.0, my_tiles, total_claimable_tiles, WIN_THRESHOLD * 100.0);
|
||||
local_context.mark_victorious();
|
||||
backend_messages.write(BackendMessage::GameEnded { outcome: GameOutcome::Victory });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if any opponent has won by occupation (which means I lost)
|
||||
for player in &game_view.players {
|
||||
if player.id != my_player_id && player.is_alive {
|
||||
let opponent_tiles = player.tile_count as usize;
|
||||
let opponent_occupation = opponent_tiles as f32 / total_claimable_tiles as f32;
|
||||
|
||||
if opponent_occupation >= WIN_THRESHOLD {
|
||||
info!("Local player defeated - {} reached {:.1}% occupation ({}/{} claimable tiles, threshold: {:.0}%)", player.name, opponent_occupation * 100.0, opponent_tiles, total_claimable_tiles, WIN_THRESHOLD * 100.0);
|
||||
local_context.mark_defeated();
|
||||
backend_messages.write(BackendMessage::GameEnded { outcome: GameOutcome::Defeat });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check victory by eliminating all opponents
|
||||
let all_opponents_dead = game_view
|
||||
.players
|
||||
.iter()
|
||||
.filter(|p| p.id != my_player_id) // Exclude me
|
||||
.all(|p| !p.is_alive);
|
||||
|
||||
if all_opponents_dead && my_player.is_alive {
|
||||
info!("Local player victorious - all opponents eliminated");
|
||||
local_context.mark_victorious();
|
||||
backend_messages.write(BackendMessage::GameEnded { outcome: GameOutcome::Victory });
|
||||
}
|
||||
}
|
||||
128
crates/borders-core/src/game/core/rng.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use bevy_ecs::prelude::*;
|
||||
use rand::SeedableRng;
|
||||
use rand::rngs::StdRng;
|
||||
|
||||
use crate::game::NationId;
|
||||
|
||||
/// Centralized deterministic RNG resource
|
||||
///
|
||||
/// This resource provides deterministic random number generation for all game systems.
|
||||
/// It is updated at the start of each turn with the current turn number, ensuring that
|
||||
/// the same sequence of turns always produces the same random values.
|
||||
///
|
||||
/// # Determinism Guarantees
|
||||
///
|
||||
/// - Same turn number + base seed + context → same RNG state
|
||||
/// - No stored RNG state in individual systems (prevents desync)
|
||||
/// - All randomness flows through this single source of truth
|
||||
///
|
||||
/// # Usage
|
||||
///
|
||||
/// Systems should never store RNG state. Instead, request context-specific RNG:
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// fn my_system(rng: Res<DeterministicRng>) {
|
||||
/// let mut player_rng = rng.for_player(player_id);
|
||||
/// let random_value = player_rng.gen_range(0..10);
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Resource)]
|
||||
pub struct DeterministicRng {
|
||||
/// Base seed for the entire game (set at game start)
|
||||
base_seed: u64,
|
||||
/// Current turn number (updated each turn)
|
||||
turn_number: u64,
|
||||
}
|
||||
|
||||
impl DeterministicRng {
|
||||
/// Create a new DeterministicRng with a base seed
|
||||
pub fn new(base_seed: u64) -> Self {
|
||||
Self { base_seed, turn_number: 0 }
|
||||
}
|
||||
|
||||
/// Update the turn number (should be called at start of each turn)
|
||||
pub fn update_turn(&mut self, turn_number: u64) {
|
||||
self.turn_number = turn_number;
|
||||
}
|
||||
|
||||
/// Get the current turn number
|
||||
#[inline]
|
||||
pub fn turn_number(&self) -> u64 {
|
||||
self.turn_number
|
||||
}
|
||||
|
||||
/// Create an RNG for a specific context within the current turn
|
||||
///
|
||||
/// The context_id allows different systems/entities to have independent
|
||||
/// random sequences while maintaining determinism.
|
||||
#[inline]
|
||||
pub fn for_context(&self, context_id: u64) -> StdRng {
|
||||
let seed = self
|
||||
.turn_number
|
||||
.wrapping_mul(997) // Prime multiplier for turn
|
||||
.wrapping_add(self.base_seed)
|
||||
.wrapping_add(context_id.wrapping_mul(1009)); // Prime multiplier for context
|
||||
StdRng::seed_from_u64(seed)
|
||||
}
|
||||
|
||||
/// Get an RNG for a specific player's actions this turn
|
||||
///
|
||||
/// This is a convenience wrapper around `for_context` for player-specific randomness.
|
||||
#[inline]
|
||||
pub fn for_player(&self, id: NationId) -> StdRng {
|
||||
self.for_context(id.get() as u64)
|
||||
}
|
||||
|
||||
/// Get an RNG for a specific tile's calculations this turn
|
||||
///
|
||||
/// Useful for tile-based randomness that should be consistent within a turn.
|
||||
pub fn for_tile(&self, tile: glam::U16Vec2) -> StdRng {
|
||||
// Use large offset to avoid collision with player IDs
|
||||
// Convert tile position to unique ID
|
||||
let tile_id = (tile.y as u64) * u16::MAX as u64 + (tile.x as u64);
|
||||
self.for_context(1_000_000 + tile_id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rand::Rng;
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_same_turn_same_seed() {
|
||||
let rng1 = DeterministicRng::new(12345);
|
||||
let rng2 = DeterministicRng::new(12345);
|
||||
|
||||
let mut player_rng1 = rng1.for_player(NationId::new(0).unwrap());
|
||||
let mut player_rng2 = rng2.for_player(NationId::new(0).unwrap());
|
||||
|
||||
assert_eq!(player_rng1.random::<u64>(), player_rng2.random::<u64>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_different_context() {
|
||||
let rng = DeterministicRng::new(12345);
|
||||
|
||||
let mut player0_rng = rng.for_player(NationId::new(0).unwrap());
|
||||
let mut player1_rng = rng.for_player(NationId::new(1).unwrap());
|
||||
|
||||
// Different contexts should produce different values
|
||||
assert_ne!(player0_rng.random::<u64>(), player1_rng.random::<u64>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_turn_update() {
|
||||
let mut rng = DeterministicRng::new(12345);
|
||||
|
||||
let mut turn0_rng = rng.for_player(NationId::new(0).unwrap());
|
||||
let value_turn0 = turn0_rng.random::<u64>();
|
||||
|
||||
rng.update_turn(1);
|
||||
let mut turn1_rng = rng.for_player(NationId::new(0).unwrap());
|
||||
let value_turn1 = turn1_rng.random::<u64>();
|
||||
|
||||
// Same player, different turns should produce different values
|
||||
assert_ne!(value_turn0, value_turn1);
|
||||
}
|
||||
}
|
||||
193
crates/borders-core/src/game/core/turn_execution.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use glam::U16Vec2;
|
||||
|
||||
use crate::game::ai::bot::Bot;
|
||||
use crate::game::combat::ActiveAttacks;
|
||||
use crate::game::core::action::GameAction;
|
||||
use crate::game::core::instance::TroopCount;
|
||||
use crate::game::core::rng::DeterministicRng;
|
||||
use crate::game::entities::{Dead, HumanPlayerCount, PlayerEntityMap, TerritorySize, Troops, remove_troops};
|
||||
use crate::game::terrain::data::TerrainData;
|
||||
use crate::game::world::{NationId, TerritoryManager};
|
||||
use crate::networking::{Intent, Turn};
|
||||
|
||||
/// Execute bot AI to generate actions
|
||||
/// This must be called before execute_turn to avoid query conflicts
|
||||
/// Returns (player_id, action) pairs
|
||||
pub fn process_bot_actions(turn_number: u64, territory_manager: &TerritoryManager, terrain: &TerrainData, player_borders: &HashMap<NationId, &HashSet<U16Vec2>>, rng_seed: u64, bots: &mut Query<(&NationId, &Troops, &TerritorySize, &mut Bot), Without<Dead>>) -> Vec<(NationId, GameAction)> {
|
||||
let alive_bot_count = bots.iter().count();
|
||||
let _guard = tracing::trace_span!("bot_processing", alive_bot_count).entered();
|
||||
|
||||
let mut bot_actions = Vec::new();
|
||||
|
||||
for (nation_id, troops, _territory_size, mut bot) in &mut *bots {
|
||||
if let Some(action) = bot.tick(turn_number, *nation_id, troops, territory_manager, terrain, player_borders, rng_seed) {
|
||||
bot_actions.push((*nation_id, action));
|
||||
}
|
||||
}
|
||||
|
||||
bot_actions
|
||||
}
|
||||
|
||||
/// Execute a full game turn
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn execute_turn(turn: &Turn, turn_number: u64, bot_actions: Vec<(NationId, GameAction)>, territory_manager: &mut TerritoryManager, terrain: &TerrainData, active_attacks: &mut ActiveAttacks, rng: &mut DeterministicRng, player_borders: &HashMap<NationId, &HashSet<U16Vec2>>, entity_map: &PlayerEntityMap, players: &mut Query<(&mut Troops, &mut TerritorySize)>, commands: &mut Commands, human_count: &HumanPlayerCount, launch_ship_writer: &mut MessageWriter<crate::game::ships::LaunchShipEvent>) {
|
||||
let _guard = tracing::trace_span!("execute_turn", turn_number, intent_count = turn.intents.len(), bot_action_count = bot_actions.len()).entered();
|
||||
|
||||
// Update RNG for this turn
|
||||
rng.update_turn(turn_number);
|
||||
|
||||
// PHASE 1: Process bot actions (deterministic, based on turn N-1 state)
|
||||
{
|
||||
let _guard = tracing::trace_span!("apply_bot_actions", count = bot_actions.len()).entered();
|
||||
|
||||
for (player_id, action) in bot_actions {
|
||||
apply_action(player_id, action, turn_number, territory_manager, terrain, active_attacks, rng, player_borders, entity_map, players, commands, launch_ship_writer);
|
||||
}
|
||||
}
|
||||
|
||||
// PHASE 2: Process player intents (from network)
|
||||
for sourced_intent in &turn.intents {
|
||||
match &sourced_intent.intent {
|
||||
Intent::Action(action) => {
|
||||
apply_action(sourced_intent.source, action.clone(), turn_number, territory_manager, terrain, active_attacks, rng, player_borders, entity_map, players, commands, launch_ship_writer);
|
||||
}
|
||||
Intent::SetSpawn { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
// PHASE 3: Tick game systems (attacks, etc.)
|
||||
active_attacks.tick(entity_map, players, commands, territory_manager, terrain, player_borders, rng, human_count);
|
||||
}
|
||||
|
||||
/// Apply a game action (attack or ship launch)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn apply_action(player_id: NationId, action: GameAction, turn_number: u64, territory_manager: &TerritoryManager, terrain: &TerrainData, active_attacks: &mut ActiveAttacks, rng: &DeterministicRng, player_borders: &HashMap<NationId, &HashSet<U16Vec2>>, entity_map: &PlayerEntityMap, players: &mut Query<(&mut Troops, &mut TerritorySize)>, commands: &mut Commands, launch_ship_writer: &mut MessageWriter<crate::game::ships::LaunchShipEvent>) {
|
||||
match action {
|
||||
GameAction::Attack { target, troops } => {
|
||||
handle_attack(player_id, target, troops, turn_number, territory_manager, terrain, active_attacks, rng, player_borders, entity_map, players, commands);
|
||||
}
|
||||
GameAction::LaunchShip { target_tile, troops } => {
|
||||
launch_ship_writer.write(crate::game::ships::LaunchShipEvent { player_id, target_tile, troops });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle player spawn at a given tile
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn handle_spawn(player_id: NationId, tile: U16Vec2, territory_manager: &mut TerritoryManager, terrain: &TerrainData, active_attacks: &mut ActiveAttacks, rng: &DeterministicRng, entity_map: &PlayerEntityMap, players: &mut Query<(&mut Troops, &mut TerritorySize)>, _commands: &mut Commands) {
|
||||
if territory_manager.has_owner(tile) || !terrain.is_conquerable(tile) {
|
||||
tracing::debug!(
|
||||
player_id = %player_id,
|
||||
?tile,
|
||||
"Spawn on occupied/water tile ignored"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Claim 5x5 territory around spawn point
|
||||
let size = territory_manager.size();
|
||||
|
||||
// We need to work with territory data directly to use the helper function
|
||||
// Convert TerritoryManager slice to Vec for modification
|
||||
let mut territories: Vec<_> = territory_manager.as_slice().to_vec();
|
||||
let changed = crate::game::systems::spawn_territory::claim_spawn_territory(tile, player_id, &mut territories, terrain, size);
|
||||
|
||||
// Apply changes back to TerritoryManager
|
||||
if !changed.is_empty() {
|
||||
for &tile_pos in &changed {
|
||||
territory_manager.conquer(tile_pos, player_id);
|
||||
}
|
||||
|
||||
// Update player stats
|
||||
if let Some(&entity) = entity_map.0.get(&player_id)
|
||||
&& let Ok((_, mut territory_size)) = players.get_mut(entity)
|
||||
{
|
||||
territory_size.0 += changed.len() as u32;
|
||||
}
|
||||
|
||||
// Notify active attacks that territory changed
|
||||
for &t in &changed {
|
||||
active_attacks.handle_territory_add(t, player_id, territory_manager, terrain, rng);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle an attack action
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn handle_attack(player_id: NationId, target: Option<NationId>, troops: u32, turn_number: u64, territory_manager: &TerritoryManager, terrain: &TerrainData, active_attacks: &mut ActiveAttacks, rng: &DeterministicRng, player_borders: &HashMap<NationId, &HashSet<U16Vec2>>, entity_map: &PlayerEntityMap, players: &mut Query<(&mut Troops, &mut TerritorySize)>, commands: &mut Commands) {
|
||||
handle_attack_internal(player_id, target, TroopCount::Absolute(troops), true, None, turn_number, territory_manager, terrain, active_attacks, rng, player_borders, entity_map, players, commands);
|
||||
}
|
||||
|
||||
/// Handle attack with specific border tiles and troop allocation
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn handle_attack_internal(player_id: NationId, target: Option<NationId>, troop_count: TroopCount, deduct_from_player: bool, border_tiles: Option<&HashSet<U16Vec2>>, turn_number: u64, territory_manager: &TerritoryManager, terrain: &TerrainData, active_attacks: &mut ActiveAttacks, rng: &DeterministicRng, player_borders: &HashMap<NationId, &HashSet<U16Vec2>>, entity_map: &PlayerEntityMap, players: &mut Query<(&mut Troops, &mut TerritorySize)>, _commands: &mut Commands) {
|
||||
// Validate not attacking self
|
||||
if target == Some(player_id) {
|
||||
tracing::debug!(
|
||||
player_id = ?player_id,
|
||||
"Attack on own nation ignored"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let troops = match troop_count {
|
||||
TroopCount::Ratio(ratio) => {
|
||||
let Some(&entity) = entity_map.0.get(&player_id) else {
|
||||
return;
|
||||
};
|
||||
if let Ok((troops, _)) = players.get(entity) {
|
||||
troops.0 * ratio
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
TroopCount::Absolute(count) => count as f32,
|
||||
};
|
||||
|
||||
// Clamp troops to available and deduct from player's pool when creating the attack (if requested)
|
||||
if deduct_from_player {
|
||||
let Some(&entity) = entity_map.0.get(&player_id) else {
|
||||
return;
|
||||
};
|
||||
if let Ok((mut troops_comp, _)) = players.get_mut(entity) {
|
||||
let available = troops_comp.0;
|
||||
let clamped_troops = troops.min(available);
|
||||
|
||||
if troops > available {
|
||||
tracing::warn!(
|
||||
player_id = ?player_id,
|
||||
requested = troops,
|
||||
available = available,
|
||||
"Attack requested more troops than available, clamping to available"
|
||||
);
|
||||
}
|
||||
|
||||
troops_comp.0 = remove_troops(troops_comp.0, clamped_troops);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let border_tiles_to_use = border_tiles.or_else(|| player_borders.get(&player_id).copied());
|
||||
|
||||
match target {
|
||||
None => {
|
||||
// Attack unclaimed territory
|
||||
if entity_map.0.contains_key(&player_id) {
|
||||
active_attacks.schedule_unclaimed(player_id, troops, border_tiles_to_use, territory_manager, terrain, player_borders, turn_number, rng);
|
||||
}
|
||||
}
|
||||
Some(target_id) => {
|
||||
// Attack specific nation
|
||||
let attacker_exists = entity_map.0.contains_key(&player_id);
|
||||
let target_exists = entity_map.0.contains_key(&target_id);
|
||||
|
||||
if attacker_exists && target_exists {
|
||||
active_attacks.schedule_attack(player_id, target_id, troops, border_tiles_to_use, territory_manager, terrain, player_borders, turn_number, rng);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
82
crates/borders-core/src/game/core/utils.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use glam::U16Vec2;
|
||||
|
||||
/// Serde helper for U16Vec2 serialization
|
||||
pub mod u16vec2_serde {
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
pub fn serialize<S>(vec: &glam::U16Vec2, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
(vec.x, vec.y).serialize(serializer)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<glam::U16Vec2, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let (x, y) = <(u16, u16)>::deserialize(deserializer)?;
|
||||
Ok(glam::U16Vec2::new(x, y))
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an iterator over all valid cardinal neighbors of a tile position.
|
||||
///
|
||||
/// Yields positions for left, right, up, and down neighbors that are within bounds.
|
||||
/// Handles boundary checks for the 4-connected grid.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use glam::U16Vec2;
|
||||
/// use borders_core::game::utils::neighbors;
|
||||
///
|
||||
/// let size = U16Vec2::new(10, 10);
|
||||
/// let tile = U16Vec2::new(5, 5);
|
||||
/// let neighbor_count = neighbors(tile, size).count();
|
||||
/// assert_eq!(neighbor_count, 4);
|
||||
/// ```
|
||||
pub fn neighbors(tile: U16Vec2, size: U16Vec2) -> impl Iterator<Item = U16Vec2> {
|
||||
const CARDINAL_DIRECTIONS: [(i32, i32); 4] = [(-1, 0), (1, 0), (0, -1), (0, 1)];
|
||||
|
||||
let tile_i32 = (tile.x as i32, tile.y as i32);
|
||||
let width = size.x as i32;
|
||||
let height = size.y as i32;
|
||||
|
||||
CARDINAL_DIRECTIONS.into_iter().filter_map(move |(dx, dy)| {
|
||||
let nx = tile_i32.0 + dx;
|
||||
let ny = tile_i32.1 + dy;
|
||||
if nx >= 0 && ny >= 0 && nx < width && ny < height { Some(U16Vec2::new(nx as u16, ny as u16)) } else { None }
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_corner_tile_neighbors() {
|
||||
let neighbor_vec: Vec<_> = neighbors(U16Vec2::new(0, 0), U16Vec2::new(10, 10)).collect();
|
||||
assert_eq!(neighbor_vec.len(), 2);
|
||||
assert!(neighbor_vec.contains(&U16Vec2::new(1, 0)));
|
||||
assert!(neighbor_vec.contains(&U16Vec2::new(0, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edge_tile_neighbors() {
|
||||
let neighbor_vec: Vec<_> = neighbors(U16Vec2::new(5, 0), U16Vec2::new(10, 10)).collect();
|
||||
assert_eq!(neighbor_vec.len(), 3);
|
||||
assert!(neighbor_vec.contains(&U16Vec2::new(4, 0)));
|
||||
assert!(neighbor_vec.contains(&U16Vec2::new(6, 0)));
|
||||
assert!(neighbor_vec.contains(&U16Vec2::new(5, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_center_tile_neighbors() {
|
||||
let neighbor_vec: Vec<_> = neighbors(U16Vec2::new(5, 5), U16Vec2::new(10, 10)).collect();
|
||||
assert_eq!(neighbor_vec.len(), 4);
|
||||
assert!(neighbor_vec.contains(&U16Vec2::new(4, 5)));
|
||||
assert!(neighbor_vec.contains(&U16Vec2::new(6, 5)));
|
||||
assert!(neighbor_vec.contains(&U16Vec2::new(5, 4)));
|
||||
assert!(neighbor_vec.contains(&U16Vec2::new(5, 6)));
|
||||
}
|
||||
}
|
||||
119
crates/borders-core/src/game/entities/components.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use bevy_ecs::prelude::*;
|
||||
use std::collections::HashSet;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
|
||||
use crate::game::core::constants::player::*;
|
||||
|
||||
/// Marker component to identify dead players
|
||||
/// Alive players are identified by the ABSENCE of this component
|
||||
/// Use Without<Dead> in queries to filter for alive players
|
||||
#[derive(Component, Debug, Clone, Copy, Default)]
|
||||
pub struct Dead;
|
||||
|
||||
/// Player name component
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct PlayerName(pub String);
|
||||
|
||||
/// Player color component
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct PlayerColor(pub HSLColor);
|
||||
|
||||
/// Border tiles component - tiles at the edge of a player's territory
|
||||
#[derive(Component, Debug, Clone, Default)]
|
||||
pub struct BorderTiles(pub HashSet<glam::U16Vec2>);
|
||||
|
||||
impl Deref for BorderTiles {
|
||||
type Target = HashSet<glam::U16Vec2>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for BorderTiles {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Troops component - current troop count
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct Troops(pub f32);
|
||||
|
||||
/// Territory size component - number of tiles owned
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct TerritorySize(pub u32);
|
||||
|
||||
/// HSL Color representation
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct HSLColor {
|
||||
pub h: f32, // Hue: 0-360
|
||||
pub s: f32, // Saturation: 0-1
|
||||
pub l: f32, // Lightness: 0-1
|
||||
}
|
||||
|
||||
impl HSLColor {
|
||||
pub fn new(h: f32, s: f32, l: f32) -> Self {
|
||||
Self { h, s, l }
|
||||
}
|
||||
|
||||
pub fn to_rgba(&self) -> [f32; 4] {
|
||||
let c = (1.0 - (2.0 * self.l - 1.0).abs()) * self.s;
|
||||
let h_prime = self.h / 60.0;
|
||||
let x = c * (1.0 - ((h_prime % 2.0) - 1.0).abs());
|
||||
|
||||
let (r1, g1, b1) = if h_prime < 1.0 {
|
||||
(c, x, 0.0)
|
||||
} else if h_prime < 2.0 {
|
||||
(x, c, 0.0)
|
||||
} else if h_prime < 3.0 {
|
||||
(0.0, c, x)
|
||||
} else if h_prime < 4.0 {
|
||||
(0.0, x, c)
|
||||
} else if h_prime < 5.0 {
|
||||
(x, 0.0, c)
|
||||
} else {
|
||||
(c, 0.0, x)
|
||||
};
|
||||
|
||||
let m = self.l - c / 2.0;
|
||||
[r1 + m, g1 + m, b1 + m, 1.0]
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate maximum troop capacity based on territory size
|
||||
#[inline]
|
||||
pub fn calculate_max_troops(territory_size: u32, is_bot: bool) -> f32 {
|
||||
let base_max = MAX_TROOPS_MULTIPLIER * ((territory_size as f32).powf(MAX_TROOPS_POWER) * MAX_TROOPS_SCALE + MAX_TROOPS_BASE);
|
||||
|
||||
if is_bot { base_max * BOT_MAX_TROOPS_MULTIPLIER } else { base_max }
|
||||
}
|
||||
|
||||
/// Calculate income for this tick based on current troops and territory
|
||||
#[inline]
|
||||
pub fn calculate_income(troops: f32, territory_size: u32, is_bot: bool) -> f32 {
|
||||
let max_troops = calculate_max_troops(territory_size, is_bot);
|
||||
|
||||
// Base income calculation
|
||||
let mut income = BASE_INCOME + (troops.powf(INCOME_POWER) / INCOME_DIVISOR);
|
||||
|
||||
// Soft cap as approaching max troops
|
||||
let ratio = 1.0 - (troops / max_troops);
|
||||
income *= ratio;
|
||||
|
||||
// Apply bot modifier
|
||||
if is_bot { income * BOT_INCOME_MULTIPLIER } else { income }
|
||||
}
|
||||
|
||||
/// Add troops with max cap enforcement
|
||||
#[inline]
|
||||
pub fn add_troops_capped(current: f32, amount: f32, territory_size: u32, is_bot: bool) -> f32 {
|
||||
let max_troops = calculate_max_troops(territory_size, is_bot);
|
||||
(current + amount).min(max_troops)
|
||||
}
|
||||
|
||||
/// Remove troops, ensuring non-negative result
|
||||
#[inline]
|
||||
pub fn remove_troops(current: f32, amount: f32) -> f32 {
|
||||
(current - amount).max(0.0)
|
||||
}
|
||||
25
crates/borders-core/src/game/entities/entity_map.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use bevy_ecs::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::game::world::NationId;
|
||||
|
||||
/// Maps nation IDs to their ECS entities for O(1) lookup
|
||||
///
|
||||
/// This resource enables systems to quickly find a player's entity
|
||||
/// by their nation_id without iterating through all entities.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct PlayerEntityMap(pub HashMap<NationId, Entity>);
|
||||
|
||||
/// The nation ID of the local client
|
||||
///
|
||||
/// This identifies which player entity corresponds to the local human player
|
||||
/// for client-specific UI and game state.
|
||||
#[derive(Resource, Debug, Clone, Copy)]
|
||||
pub struct ClientPlayerId(pub NationId);
|
||||
|
||||
/// The number of human players in the game
|
||||
///
|
||||
/// Used to determine if a player is a bot (player_id >= human_count).
|
||||
/// Currently always 1, but kept for future multiplayer support.
|
||||
#[derive(Resource, Debug, Clone, Copy)]
|
||||
pub struct HumanPlayerCount(pub u16);
|
||||
9
crates/borders-core/src/game/entities/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
//! Entity management module
|
||||
//!
|
||||
//! This module contains all player/entity-related types and management.
|
||||
|
||||
pub mod components;
|
||||
pub mod entity_map;
|
||||
|
||||
pub use components::*;
|
||||
pub use entity_map::*;
|
||||
69
crates/borders-core/src/game/input/context.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::game::NationId;
|
||||
|
||||
/// Represents the outcome for a specific player (local, not shared)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PlayerOutcome {
|
||||
/// Player has won the game
|
||||
Victory,
|
||||
/// Player has been eliminated/defeated
|
||||
Defeat,
|
||||
}
|
||||
|
||||
/// Local player context - CLIENT-SPECIFIC state, NOT part of deterministic game state
|
||||
///
|
||||
/// **Important: This is LOCAL context, not shared/deterministic state!**
|
||||
///
|
||||
/// This resource contains information specific to THIS client's perspective:
|
||||
/// - Which player ID this client controls
|
||||
/// - Whether this player won/lost (irrelevant to other clients)
|
||||
/// - Whether this client can send commands or is spectating
|
||||
///
|
||||
/// This state is NOT synchronized across clients and is NOT part of
|
||||
/// GameView which must be identical on all clients for determinism.
|
||||
///
|
||||
/// In multiplayer:
|
||||
/// - Each client has their own LocalPlayerContext with different player IDs
|
||||
/// - One client may have `my_outcome = Victory` while others have `Defeat`
|
||||
/// - A spectator would have `can_send_intents = false`
|
||||
/// - The shared game state continues running regardless
|
||||
#[derive(Resource)]
|
||||
pub struct LocalPlayerContext {
|
||||
/// The player ID for this client
|
||||
pub id: NationId,
|
||||
|
||||
/// The outcome for this specific player (if determined)
|
||||
/// None = still playing, Some(Victory/Defeat) = game ended for this player
|
||||
pub my_outcome: Option<PlayerOutcome>,
|
||||
|
||||
/// Whether this client can send intents (false when defeated or spectating)
|
||||
pub can_send_intents: bool,
|
||||
}
|
||||
|
||||
impl LocalPlayerContext {
|
||||
/// Create a new local player context for the given player ID
|
||||
pub fn new(id: NationId) -> Self {
|
||||
Self { id, my_outcome: None, can_send_intents: true }
|
||||
}
|
||||
|
||||
/// Mark the local player as defeated
|
||||
pub fn mark_defeated(&mut self) {
|
||||
self.my_outcome = Some(PlayerOutcome::Defeat);
|
||||
self.can_send_intents = false;
|
||||
}
|
||||
|
||||
/// Mark the local player as victorious
|
||||
pub fn mark_victorious(&mut self) {
|
||||
self.my_outcome = Some(PlayerOutcome::Victory);
|
||||
// Player can still send intents after victory (to continue playing if desired)
|
||||
// Or set to false if you want to prevent further actions
|
||||
}
|
||||
|
||||
/// Check if the local player is still actively playing
|
||||
#[inline]
|
||||
pub fn is_playing(&self) -> bool {
|
||||
self.my_outcome.is_none() && self.can_send_intents
|
||||
}
|
||||
}
|
||||
285
crates/borders-core/src/game/input/handlers.rs
Normal file
@@ -0,0 +1,285 @@
|
||||
//! Platform-agnostic input handling systems
|
||||
//!
|
||||
//! These systems use InputState instead of Bevy's input queries,
|
||||
//! making them work across both WASM and desktop platforms with
|
||||
//! Pixi.js rendering.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use tracing::{debug, info, trace};
|
||||
|
||||
use crate::game::core::constants::input::*;
|
||||
use crate::game::view::GameView;
|
||||
use crate::game::{GameAction, LocalPlayerContext, SpawnManager};
|
||||
use crate::networking::{Intent, IntentEvent};
|
||||
use crate::ui::input::{InputState, KeyCode, MouseButton};
|
||||
|
||||
/// Resource tracking whether spawn phase is active
|
||||
#[derive(Resource, Default)]
|
||||
pub struct SpawnPhase {
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
/// Resource for attack control settings
|
||||
#[derive(Resource)]
|
||||
pub struct AttackControls {
|
||||
pub attack_ratio: f32,
|
||||
}
|
||||
|
||||
impl Default for AttackControls {
|
||||
fn default() -> Self {
|
||||
Self { attack_ratio: DEFAULT_ATTACK_RATIO }
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle placing the human spawn by clicking on valid land
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn handle_spawn_click_system(input_state: NonSend<Arc<Mutex<InputState>>>, spawn_phase: If<Res<SpawnPhase>>, game_view: Option<ResMut<GameView>>, local_context: Option<Res<LocalPlayerContext>>, mut spawn_manager: Option<ResMut<SpawnManager>>, mut spawn_timeout: Option<ResMut<crate::game::SpawnTimeout>>, mut intent_writer: MessageWriter<IntentEvent>, territory_manager: Option<Res<crate::game::TerritoryManager>>, terrain: Option<Res<crate::game::terrain::TerrainData>>) {
|
||||
if !spawn_phase.active {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(input) = input_state.lock() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !input.mouse_just_released(MouseButton::Left) {
|
||||
return;
|
||||
}
|
||||
|
||||
let _guard = tracing::trace_span!("spawn_click").entered();
|
||||
|
||||
// Frontend handles camera interaction filtering, but double-check here
|
||||
if input.had_camera_interaction() {
|
||||
trace!("Spawn click ignored - camera interaction detected");
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(game_view) = game_view else {
|
||||
debug!("Spawn click ignored - GameView not ready");
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(local_context) = local_context else {
|
||||
debug!("Spawn click ignored - LocalPlayerContext not ready");
|
||||
return;
|
||||
};
|
||||
|
||||
// Can't spawn if not allowed to send intents
|
||||
if !local_context.can_send_intents {
|
||||
debug!("Spawn click ignored - cannot send intents");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get tile from InputState (set by frontend)
|
||||
let Some(tile_coord) = input.cursor_tile() else {
|
||||
debug!("Spawn click ignored - cursor not over valid tile");
|
||||
return;
|
||||
};
|
||||
|
||||
let tile_idx = crate::ui::tile_to_index(tile_coord, game_view.width()) as u32;
|
||||
|
||||
let tile_ownership = game_view.get_ownership(tile_idx);
|
||||
if tile_ownership.is_owned() {
|
||||
debug!("Spawn click on tile {:?} ignored - occupied", tile_coord);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if tile is water/unconquerable
|
||||
if let Some(ref terrain_data) = terrain
|
||||
&& !terrain_data.is_conquerable(tile_coord)
|
||||
{
|
||||
debug!("Spawn click on tile {:?} ignored - water or unconquerable", tile_coord);
|
||||
return;
|
||||
}
|
||||
|
||||
// Player has chosen a spawn location - send to server
|
||||
info!("Player {} setting spawn at tile {:?}", local_context.id.get(), tile_coord);
|
||||
|
||||
// Check if this is the first spawn (timer not started yet)
|
||||
let is_first_spawn = if let Some(ref spawn_mgr) = spawn_manager { spawn_mgr.get_player_spawns().is_empty() } else { true };
|
||||
|
||||
// Send SetSpawn intent to server (not Action - this won't be in game history)
|
||||
// Server will validate, track, and eventually send Turn(0) when timeout expires
|
||||
intent_writer.write(IntentEvent(Intent::SetSpawn { tile_index: tile_coord }));
|
||||
|
||||
// Start spawn timeout on first spawn (spawn_phase plugin will emit countdown updates)
|
||||
if is_first_spawn && let Some(ref mut timeout) = spawn_timeout {
|
||||
timeout.start();
|
||||
info!("Spawn timeout started ({:.1}s)", timeout.duration_secs);
|
||||
}
|
||||
|
||||
// Update local spawn manager for preview/bot recalculation
|
||||
// Note: This only updates the spawn manager, not the game instance
|
||||
// The actual game state is updated when Turn(0) is processed
|
||||
if let Some(ref mut spawn_mgr) = spawn_manager
|
||||
&& let Some(ref territory_mgr) = territory_manager
|
||||
&& let Some(ref terrain_data) = terrain
|
||||
{
|
||||
// Update spawn manager (triggers bot spawn recalculation)
|
||||
spawn_mgr.update_player_spawn(local_context.id, tile_coord, territory_mgr, terrain_data);
|
||||
|
||||
info!("Spawn manager updated with player {} spawn at tile {:?}", local_context.id.get(), tile_coord);
|
||||
info!("Total spawns in manager: {}", spawn_mgr.get_all_spawns().len());
|
||||
}
|
||||
}
|
||||
|
||||
/// Center the camera on the client's spawn (hotkey C)
|
||||
/// Note: Camera commands are not currently implemented in the backend
|
||||
pub fn handle_center_camera_system(input_state: NonSend<Arc<Mutex<InputState>>>, game_view: Option<Res<GameView>>, local_context: Option<Res<LocalPlayerContext>>) {
|
||||
let Ok(input) = input_state.lock() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !input.key_just_pressed(KeyCode::KeyC) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(game_view) = game_view else {
|
||||
return; // GameView not ready yet
|
||||
};
|
||||
|
||||
let Some(local_context) = local_context else {
|
||||
return; // LocalPlayerContext not ready yet
|
||||
};
|
||||
|
||||
// Find any owned tile to center on
|
||||
if let Some(_tile) = game_view.find_tile_owned_by(local_context.id) {
|
||||
// TODO: Implement camera centering when camera commands are added
|
||||
tracing::debug!("Camera center requested (not implemented)");
|
||||
}
|
||||
}
|
||||
|
||||
/// After spawn, clicking tiles triggers expansion/attack based on ownership
|
||||
/// Automatically detects if a ship is needed for water attacks
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn handle_attack_click_system(input_state: NonSend<Arc<Mutex<InputState>>>, spawn_phase: If<Res<SpawnPhase>>, game_view: If<Res<GameView>>, terrain: If<Res<crate::game::terrain::TerrainData>>, coastal_tiles: If<Res<crate::game::CoastalTiles>>, local_context: If<Res<LocalPlayerContext>>, attack_controls: If<Res<AttackControls>>, mut intent_writer: MessageWriter<IntentEvent>, entity_map: If<Res<crate::game::PlayerEntityMap>>, border_query: Query<&crate::game::BorderTiles>, troops_query: Query<&crate::game::Troops>) {
|
||||
if spawn_phase.active {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(input) = input_state.lock() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !input.mouse_just_released(MouseButton::Left) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Frontend handles camera interaction filtering
|
||||
if input.had_camera_interaction() {
|
||||
return;
|
||||
}
|
||||
|
||||
let _guard = tracing::trace_span!("attack_click").entered();
|
||||
|
||||
// Can't attack if not allowed to send intents (defeated/spectating)
|
||||
if !local_context.can_send_intents {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get tile from InputState (set by frontend)
|
||||
let Some(tile_coord) = input.cursor_tile() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let tile_idx = crate::ui::tile_to_index(tile_coord, game_view.width()) as u32;
|
||||
let tile_ownership = game_view.get_ownership(tile_idx);
|
||||
let player_id = local_context.id;
|
||||
|
||||
// Can't attack own tiles
|
||||
if tile_ownership.is_owned_by(player_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if target is water - ignore water clicks
|
||||
let size = game_view.size();
|
||||
if terrain.is_navigable(tile_coord) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if target is connected to player's territory
|
||||
let territories: Vec<crate::game::TileOwnership> = game_view.territories.iter().copied().collect();
|
||||
|
||||
let is_connected = crate::game::connectivity::is_connected_to_player(&territories, &terrain, tile_coord, player_id, size);
|
||||
|
||||
if is_connected {
|
||||
// Target is connected to player's territory - use normal attack
|
||||
// Calculate absolute troop count from ratio
|
||||
let troops = if let Some(&entity) = (*entity_map).0.get(&player_id)
|
||||
&& let Ok(troops_comp) = troops_query.get(entity)
|
||||
{
|
||||
(troops_comp.0 * attack_controls.attack_ratio).floor() as u32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
intent_writer.write(IntentEvent(Intent::Action(GameAction::Attack { target: tile_ownership.nation_id(), troops })));
|
||||
return;
|
||||
}
|
||||
|
||||
// Target is NOT connected - need to use ship
|
||||
debug!("Target {:?} not connected to player territory, attempting ship launch", tile_coord);
|
||||
|
||||
// Find target's nearest coastal tile
|
||||
let target_coastal_tile = crate::game::connectivity::find_coastal_tile_in_region(&territories, &terrain, tile_coord, size);
|
||||
|
||||
let Some(target_coastal_tile) = target_coastal_tile else {
|
||||
debug!("No coastal tile found in target's region for tile {:?}", tile_coord);
|
||||
return;
|
||||
};
|
||||
|
||||
// Find player's nearest coastal tile using O(1) entity lookup
|
||||
let player_border_tiles = (*entity_map).0.get(&player_id).and_then(|&entity| border_query.get(entity).ok());
|
||||
|
||||
let launch_tile = player_border_tiles.and_then(|tiles| crate::game::ships::pathfinding::find_nearest_player_coastal_tile(coastal_tiles.tiles(), tiles, target_coastal_tile));
|
||||
|
||||
let Some(launch_tile) = launch_tile else {
|
||||
debug!("Player has no coastal tiles to launch ship from");
|
||||
return;
|
||||
};
|
||||
|
||||
debug!("Found launch tile {:?} and target coastal tile {:?} for target {:?}", launch_tile, target_coastal_tile, tile_coord);
|
||||
|
||||
// Try to find a water path from launch tile to target coastal tile
|
||||
let path = crate::game::ships::pathfinding::find_water_path(&terrain, launch_tile, target_coastal_tile, crate::game::ships::MAX_PATH_LENGTH);
|
||||
|
||||
if let Some(_path) = path {
|
||||
// We can reach the target by ship!
|
||||
// Calculate absolute troop count from ratio
|
||||
let troops = if let Some(&entity) = (*entity_map).0.get(&player_id)
|
||||
&& let Ok(troops_comp) = troops_query.get(entity)
|
||||
{
|
||||
(troops_comp.0 * attack_controls.attack_ratio).floor() as u32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
debug!("Launching ship to target {:?} with {} troops", tile_coord, troops);
|
||||
|
||||
intent_writer.write(IntentEvent(Intent::Action(GameAction::LaunchShip { target_tile: tile_coord, troops })));
|
||||
} else {
|
||||
debug!("No water path found from {:?} to {:?}", launch_tile, target_coastal_tile);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjust attack ratio with keys 1/2
|
||||
pub fn handle_attack_ratio_keys_system(input_state: NonSend<Arc<Mutex<InputState>>>, mut controls: If<ResMut<AttackControls>>) {
|
||||
let Ok(input) = input_state.lock() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut changed = false;
|
||||
if input.key_just_pressed(KeyCode::Digit1) {
|
||||
controls.attack_ratio = (controls.attack_ratio - ATTACK_RATIO_STEP).max(ATTACK_RATIO_MIN);
|
||||
changed = true;
|
||||
}
|
||||
if input.key_just_pressed(KeyCode::Digit2) {
|
||||
controls.attack_ratio = (controls.attack_ratio + ATTACK_RATIO_STEP).min(ATTACK_RATIO_MAX);
|
||||
changed = true;
|
||||
}
|
||||
if changed {
|
||||
debug!("Attack ratio changed to {:.1}", controls.attack_ratio);
|
||||
}
|
||||
}
|
||||
9
crates/borders-core/src/game/input/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
//! Player input handling
|
||||
//!
|
||||
//! This module handles player input events and local player context.
|
||||
|
||||
pub mod context;
|
||||
pub mod handlers;
|
||||
|
||||
pub use context::*;
|
||||
pub use handlers::*;
|
||||
27
crates/borders-core/src/game/mod.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
//! Game logic and state management
|
||||
//!
|
||||
//! This module contains all game-related functionality organized by domain.
|
||||
|
||||
// Core modules
|
||||
pub mod ai;
|
||||
pub mod combat;
|
||||
pub mod core;
|
||||
pub mod entities;
|
||||
pub mod input;
|
||||
pub mod ships;
|
||||
pub mod systems;
|
||||
pub mod terrain;
|
||||
pub mod view;
|
||||
pub mod world;
|
||||
|
||||
// Re-exports from submodules
|
||||
pub use combat::*;
|
||||
pub use core::*;
|
||||
pub use entities::*;
|
||||
pub use input::*;
|
||||
pub use ships::*;
|
||||
pub use systems::*;
|
||||
pub use terrain::*;
|
||||
pub use view::*;
|
||||
pub use world::NationId;
|
||||
pub use world::*;
|
||||
100
crates/borders-core/src/game/ships/components.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use bevy_ecs::prelude::*;
|
||||
use glam::U16Vec2;
|
||||
|
||||
/// Ship component containing all ship state
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct Ship {
|
||||
pub id: u32,
|
||||
pub troops: u32,
|
||||
pub path: Vec<U16Vec2>,
|
||||
pub current_path_index: usize,
|
||||
pub ticks_per_tile: u32,
|
||||
pub ticks_since_move: u32,
|
||||
pub launch_tick: u64,
|
||||
pub target_tile: U16Vec2,
|
||||
}
|
||||
|
||||
impl Ship {
|
||||
/// Create a new ship
|
||||
pub fn new(id: u32, troops: u32, path: Vec<U16Vec2>, ticks_per_tile: u32, launch_tick: u64) -> Self {
|
||||
let target_tile = *path.last().unwrap_or(&path[0]);
|
||||
|
||||
Self { id, troops, path, current_path_index: 0, ticks_per_tile, ticks_since_move: 0, launch_tick, target_tile }
|
||||
}
|
||||
|
||||
/// Update the ship's position based on the current tick
|
||||
/// Returns true if the ship has reached its destination
|
||||
pub fn update(&mut self) -> bool {
|
||||
if self.has_arrived() {
|
||||
return true;
|
||||
}
|
||||
|
||||
self.ticks_since_move += 1;
|
||||
|
||||
if self.ticks_since_move >= self.ticks_per_tile {
|
||||
self.ticks_since_move = 0;
|
||||
self.current_path_index += 1;
|
||||
|
||||
if self.has_arrived() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Get the current tile the ship is on
|
||||
#[inline]
|
||||
pub fn get_current_tile(&self) -> U16Vec2 {
|
||||
if self.current_path_index < self.path.len() { self.path[self.current_path_index] } else { self.target_tile }
|
||||
}
|
||||
|
||||
/// Check if the ship has reached its destination
|
||||
#[inline]
|
||||
pub fn has_arrived(&self) -> bool {
|
||||
self.current_path_index >= self.path.len() - 1
|
||||
}
|
||||
|
||||
/// Get interpolation factor for smooth rendering (0.0 to 1.0)
|
||||
#[inline]
|
||||
pub fn get_visual_interpolation(&self) -> f32 {
|
||||
if self.ticks_per_tile == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
self.ticks_since_move as f32 / self.ticks_per_tile as f32
|
||||
}
|
||||
|
||||
/// Get the next tile in the path (for interpolation)
|
||||
#[inline]
|
||||
pub fn get_next_tile(&self) -> Option<U16Vec2> {
|
||||
if self.current_path_index + 1 < self.path.len() { Some(self.path[self.current_path_index + 1]) } else { None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Component tracking number of ships owned by a player
|
||||
#[derive(Component, Debug, Clone, Copy, Default)]
|
||||
pub struct ShipCount(pub usize);
|
||||
|
||||
/// Resource for generating unique ship IDs
|
||||
#[derive(Resource)]
|
||||
pub struct ShipIdCounter {
|
||||
next_id: u32,
|
||||
}
|
||||
|
||||
impl ShipIdCounter {
|
||||
pub fn new() -> Self {
|
||||
Self { next_id: 1 }
|
||||
}
|
||||
|
||||
pub fn generate_id(&mut self) -> u32 {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ShipIdCounter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
15
crates/borders-core/src/game/ships/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
//! Ship system using ECS architecture.
|
||||
//!
|
||||
//! Ships are entities with parent-child relationships to players.
|
||||
//! See systems.rs for launch/update/arrival systems.
|
||||
|
||||
mod components;
|
||||
pub mod pathfinding;
|
||||
pub mod systems;
|
||||
|
||||
pub use components::*;
|
||||
pub use pathfinding::*;
|
||||
pub use systems::*;
|
||||
|
||||
// Re-export ship constants from central location
|
||||
pub use crate::game::core::constants::ships::*;
|
||||
248
crates/borders-core/src/game/ships/pathfinding.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{BinaryHeap, HashMap, HashSet};
|
||||
|
||||
use glam::U16Vec2;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::game::terrain::data::TerrainData;
|
||||
use crate::game::utils::neighbors;
|
||||
|
||||
/// A node in the pathfinding search
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
struct PathNode {
|
||||
pos: U16Vec2,
|
||||
g_cost: u32, // Cost from start
|
||||
h_cost: u32, // Heuristic cost to goal
|
||||
f_cost: u32, // Total cost (g + h)
|
||||
}
|
||||
|
||||
impl Ord for PathNode {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
// Reverse ordering for min-heap
|
||||
other.f_cost.cmp(&self.f_cost)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for PathNode {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a water path from start_tile to target_tile using A* algorithm
|
||||
/// Returns None if no path exists
|
||||
pub fn find_water_path(terrain: &TerrainData, start_tile: U16Vec2, target_tile: U16Vec2, max_path_length: usize) -> Option<Vec<U16Vec2>> {
|
||||
let size = terrain.size();
|
||||
|
||||
// Check if target is reachable (must be coastal or water)
|
||||
if !is_valid_ship_destination(terrain, target_tile, size) {
|
||||
debug!("Pathfinding failed: target {:?} is not a valid ship destination", target_tile);
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find actual water start position (adjacent to coast)
|
||||
debug!("Pathfinding: looking for water launch tile adjacent to coastal tile {:?}", start_tile);
|
||||
let water_start = find_water_launch_tile(terrain, start_tile, size)?;
|
||||
debug!("Pathfinding: found water launch tile {:?}", water_start);
|
||||
|
||||
// Find water tiles adjacent to target if target is land
|
||||
let water_targets = if terrain.is_navigable(target_tile) { vec![target_tile] } else { find_adjacent_water_tiles(terrain, target_tile, size) };
|
||||
|
||||
if water_targets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Run A* pathfinding
|
||||
let mut open_set = BinaryHeap::new();
|
||||
let mut closed_set = HashSet::new();
|
||||
let mut came_from: HashMap<U16Vec2, U16Vec2> = HashMap::new();
|
||||
let mut g_scores: HashMap<U16Vec2, u32> = HashMap::new();
|
||||
|
||||
// Initialize with start node
|
||||
let start_h = water_start.manhattan_distance(water_targets[0]) as u32;
|
||||
open_set.push(PathNode { pos: water_start, g_cost: 0, h_cost: start_h, f_cost: start_h });
|
||||
g_scores.insert(water_start, 0);
|
||||
|
||||
while let Some(current_node) = open_set.pop() {
|
||||
let current_pos = current_node.pos;
|
||||
|
||||
// Check if we've reached any of the target tiles
|
||||
if water_targets.contains(¤t_pos) {
|
||||
// Reconstruct path
|
||||
let mut path = vec![current_pos];
|
||||
let mut current_tile = current_pos;
|
||||
|
||||
while let Some(&parent) = came_from.get(¤t_tile) {
|
||||
path.push(parent);
|
||||
current_tile = parent;
|
||||
|
||||
// Prevent infinite loops
|
||||
if path.len() > max_path_length {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
path.reverse();
|
||||
|
||||
// If original target was land, add it to the end
|
||||
if !terrain.is_navigable(target_tile) {
|
||||
path.push(target_tile);
|
||||
}
|
||||
|
||||
return Some(path);
|
||||
}
|
||||
|
||||
// Skip if already processed
|
||||
if closed_set.contains(¤t_pos) {
|
||||
continue;
|
||||
}
|
||||
closed_set.insert(current_pos);
|
||||
|
||||
// Check if we've exceeded max path length
|
||||
if current_node.g_cost as usize > max_path_length {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Explore neighbors
|
||||
for neighbor in neighbors(current_pos, size) {
|
||||
if closed_set.contains(&neighbor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !terrain.is_navigable(neighbor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let tentative_g = current_node.g_cost + 1;
|
||||
|
||||
if tentative_g < *g_scores.get(&neighbor).unwrap_or(&u32::MAX) {
|
||||
came_from.insert(neighbor, current_pos);
|
||||
g_scores.insert(neighbor, tentative_g);
|
||||
|
||||
// Find best heuristic to any target
|
||||
let h_cost = water_targets.iter().map(|&t| neighbor.manhattan_distance(t) as u32).min().unwrap_or(0);
|
||||
|
||||
let f_cost = tentative_g + h_cost;
|
||||
|
||||
open_set.push(PathNode { pos: neighbor, g_cost: tentative_g, h_cost, f_cost });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Pathfinding failed: no path found from {:?} to {:?}", start_tile, target_tile);
|
||||
None
|
||||
}
|
||||
|
||||
/// Find a water tile adjacent to a coastal land tile for ship launch
|
||||
fn find_water_launch_tile(terrain: &TerrainData, coast_tile: U16Vec2, size: U16Vec2) -> Option<U16Vec2> {
|
||||
debug!("find_water_launch_tile: checking coastal tile {:?}", coast_tile);
|
||||
|
||||
let water_tile = neighbors(coast_tile, size)
|
||||
.inspect(|&neighbor| {
|
||||
if terrain.is_navigable(neighbor) {
|
||||
debug!(" Checking neighbor {:?}: is_water=true", neighbor);
|
||||
}
|
||||
})
|
||||
.find(|&neighbor| terrain.is_navigable(neighbor));
|
||||
|
||||
if let Some(tile) = water_tile {
|
||||
debug!(" Found water launch tile {:?}", tile);
|
||||
} else {
|
||||
debug!(" No water launch tile found for coastal tile {:?}", coast_tile);
|
||||
}
|
||||
|
||||
water_tile
|
||||
}
|
||||
|
||||
/// Find all water tiles adjacent to a land tile
|
||||
fn find_adjacent_water_tiles(terrain: &TerrainData, tile: U16Vec2, size: U16Vec2) -> Vec<U16Vec2> {
|
||||
neighbors(tile, size).filter(|&neighbor| terrain.is_navigable(neighbor)).collect()
|
||||
}
|
||||
|
||||
/// Check if a tile is a valid ship destination (water or coastal land)
|
||||
fn is_valid_ship_destination(terrain: &TerrainData, tile: U16Vec2, size: U16Vec2) -> bool {
|
||||
// If it's water, it's valid
|
||||
if terrain.is_navigable(tile) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If it's land, check if it's coastal
|
||||
neighbors(tile, size).any(|neighbor| terrain.is_navigable(neighbor))
|
||||
}
|
||||
|
||||
/// Simplify a path by removing unnecessary waypoints (path smoothing)
|
||||
/// This maintains determinism as it's purely geometric
|
||||
pub fn smooth_path(path: Vec<U16Vec2>, terrain: &TerrainData) -> Vec<U16Vec2> {
|
||||
if path.len() <= 2 {
|
||||
return path;
|
||||
}
|
||||
|
||||
let mut smoothed = vec![path[0]];
|
||||
let mut current_idx = 0;
|
||||
|
||||
while current_idx < path.len() - 1 {
|
||||
let mut farthest = current_idx + 1;
|
||||
|
||||
// Find the farthest point we can see directly
|
||||
for i in (current_idx + 2)..path.len() {
|
||||
if has_clear_water_line(terrain, path[current_idx], path[i]) {
|
||||
farthest = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
smoothed.push(path[farthest]);
|
||||
current_idx = farthest;
|
||||
}
|
||||
|
||||
smoothed
|
||||
}
|
||||
|
||||
/// Check if there's a clear water line between two tiles
|
||||
/// Uses Bresenham-like algorithm for deterministic line checking
|
||||
fn has_clear_water_line(terrain: &TerrainData, from: U16Vec2, to: U16Vec2) -> bool {
|
||||
let x0 = from.x as i32;
|
||||
let y0 = from.y as i32;
|
||||
let x1 = to.x as i32;
|
||||
let y1 = to.y as i32;
|
||||
|
||||
let dx = (x1 - x0).abs();
|
||||
let dy = (y1 - y0).abs();
|
||||
let sx = if x0 < x1 { 1 } else { -1 };
|
||||
let sy = if y0 < y1 { 1 } else { -1 };
|
||||
let mut err = dx - dy;
|
||||
|
||||
let mut x = x0;
|
||||
let mut y = y0;
|
||||
|
||||
loop {
|
||||
if !terrain.is_navigable(U16Vec2::new(x as u16, y as u16)) {
|
||||
return false; // Hit land
|
||||
}
|
||||
|
||||
if x == x1 && y == y1 {
|
||||
return true; // Reached target
|
||||
}
|
||||
|
||||
let e2 = 2 * err;
|
||||
if e2 > -dy {
|
||||
err -= dy;
|
||||
x += sx;
|
||||
}
|
||||
if e2 < dx {
|
||||
err += dx;
|
||||
y += sy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the nearest coastal tile owned by a player to a target tile
|
||||
/// Returns None if no valid coastal tile found
|
||||
pub fn find_nearest_player_coastal_tile(coastal_tiles: &HashSet<U16Vec2>, player_border_tiles: &HashSet<U16Vec2>, target_tile: U16Vec2) -> Option<U16Vec2> {
|
||||
let best_tile = player_border_tiles.iter().filter(|&tile| coastal_tiles.contains(tile)).min_by_key(|&tile| tile.manhattan_distance(target_tile));
|
||||
|
||||
debug!("Finding coastal tile: coastal_tiles.len={}, player_border_tiles.len={}, target_tile={:?}, best_tile={:?}", coastal_tiles.len(), player_border_tiles.len(), target_tile, best_tile);
|
||||
|
||||
best_tile.copied()
|
||||
}
|
||||
283
crates/borders-core/src/game/ships/systems.rs
Normal file
@@ -0,0 +1,283 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use bevy_ecs::hierarchy::ChildOf;
|
||||
use bevy_ecs::prelude::*;
|
||||
use glam::U16Vec2;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::game::terrain::TerrainData;
|
||||
use crate::game::{
|
||||
ActiveAttacks, CoastalTiles, DeterministicRng, PlayerEntityMap, TerritoryManager, TerritorySize, Troops,
|
||||
entities::remove_troops,
|
||||
ships::{MAX_SHIPS_PER_PLAYER, Ship, ShipCount, ShipIdCounter, TICKS_PER_TILE, TROOP_PERCENT},
|
||||
world::NationId,
|
||||
};
|
||||
|
||||
/// Event for requesting a ship launch
|
||||
#[derive(Debug, Clone, Message)]
|
||||
pub struct LaunchShipEvent {
|
||||
pub player_id: NationId,
|
||||
pub target_tile: U16Vec2,
|
||||
pub troops: u32,
|
||||
}
|
||||
|
||||
/// Event for ship arrivals at their destination
|
||||
#[derive(Debug, Clone, Message)]
|
||||
pub struct ShipArrivalEvent {
|
||||
pub owner_id: NationId,
|
||||
pub target_tile: U16Vec2,
|
||||
pub troops: u32,
|
||||
}
|
||||
|
||||
/// System to handle ship launch requests
|
||||
/// Validates launch conditions and spawns ship entities as children of players
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn launch_ship_system(mut launch_events: MessageReader<LaunchShipEvent>, mut commands: Commands, mut ship_id_counter: If<ResMut<ShipIdCounter>>, mut players: Query<(&NationId, &mut Troops, &mut ShipCount)>, current_turn: If<Res<crate::game::CurrentTurn>>, terrain: If<Res<TerrainData>>, coastal_tiles: If<Res<CoastalTiles>>, territory_manager: If<Res<TerritoryManager>>, border_cache: If<Res<crate::game::BorderCache>>, entity_map: If<Res<PlayerEntityMap>>) {
|
||||
let turn_number = current_turn.turn.turn_number;
|
||||
let size = territory_manager.size();
|
||||
let territory_slice = territory_manager.as_slice();
|
||||
|
||||
for event in launch_events.read() {
|
||||
let _guard = tracing::trace_span!(
|
||||
"launch_ship",
|
||||
player_id = ?event.player_id,
|
||||
?event.target_tile
|
||||
)
|
||||
.entered();
|
||||
|
||||
// Get player entity
|
||||
let Some(&player_entity) = (*entity_map).0.get(&event.player_id) else {
|
||||
debug!(?event.player_id, "Player not found");
|
||||
continue;
|
||||
};
|
||||
|
||||
// Get player components
|
||||
let Ok((_, mut troops, mut ship_count)) = players.get_mut(player_entity) else {
|
||||
debug!(?event.player_id, "Dead player cannot launch ships");
|
||||
continue;
|
||||
};
|
||||
|
||||
// Check ship limit
|
||||
if ship_count.0 >= MAX_SHIPS_PER_PLAYER {
|
||||
debug!(
|
||||
?event.player_id,
|
||||
"Player cannot launch ship: already has {}/{} ships",
|
||||
ship_count.0,
|
||||
MAX_SHIPS_PER_PLAYER
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check troops
|
||||
if troops.0 <= 0.0 {
|
||||
debug!(?event.player_id, "Player has no troops to launch ship");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clamp troops to available, use default 20% if 0 requested
|
||||
let troops_to_send = if event.troops > 0 {
|
||||
let available = troops.0 as u32;
|
||||
let clamped = event.troops.min(available);
|
||||
|
||||
if event.troops > available {
|
||||
debug!(
|
||||
?event.player_id,
|
||||
requested = event.troops,
|
||||
available = available,
|
||||
"Ship launch requested more troops than available, clamping"
|
||||
);
|
||||
}
|
||||
|
||||
clamped
|
||||
} else {
|
||||
// Default to 20% of troops if 0 requested
|
||||
(troops.0 * TROOP_PERCENT).floor() as u32
|
||||
};
|
||||
|
||||
if troops_to_send == 0 {
|
||||
debug!(?event.player_id, "Not enough troops to launch ship");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find target's nearest coastal tile
|
||||
let target_coastal_tile = crate::game::connectivity::find_coastal_tile_in_region(territory_slice, &terrain, event.target_tile, size);
|
||||
|
||||
let target_coastal_tile = match target_coastal_tile {
|
||||
Some(tile) => tile,
|
||||
None => {
|
||||
debug!(
|
||||
?event.player_id,
|
||||
?event.target_tile,
|
||||
"No coastal tile found in target region"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Find player's nearest coastal tile
|
||||
let player_border_tiles = border_cache.get(event.player_id);
|
||||
let launch_tile = player_border_tiles.and_then(|tiles| crate::game::ships::pathfinding::find_nearest_player_coastal_tile(coastal_tiles.tiles(), tiles, target_coastal_tile));
|
||||
|
||||
let launch_tile = match launch_tile {
|
||||
Some(tile) => tile,
|
||||
None => {
|
||||
debug!(
|
||||
?event.player_id,
|
||||
?event.target_tile,
|
||||
"Player has no coastal tiles to launch from"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate water path from launch tile to target coastal tile
|
||||
let path = {
|
||||
let _guard = tracing::trace_span!("ship_pathfinding", ?launch_tile, ?target_coastal_tile).entered();
|
||||
|
||||
crate::game::ships::pathfinding::find_water_path(&terrain, launch_tile, target_coastal_tile, crate::game::ships::MAX_PATH_LENGTH)
|
||||
};
|
||||
|
||||
let path = match path {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
debug!(
|
||||
?event.player_id,
|
||||
?event.target_tile,
|
||||
?launch_tile,
|
||||
"No water path found"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Generate ship ID
|
||||
let ship_id = ship_id_counter.generate_id();
|
||||
|
||||
// Deduct troops from player
|
||||
troops.0 = remove_troops(troops.0, troops_to_send as f32);
|
||||
|
||||
// Create ship as child of player entity
|
||||
let ship = Ship::new(ship_id, troops_to_send, path, TICKS_PER_TILE, turn_number);
|
||||
|
||||
let ship_entity = commands.spawn(ship).id();
|
||||
commands.entity(player_entity).add_child(ship_entity);
|
||||
|
||||
// Increment ship count
|
||||
ship_count.0 += 1;
|
||||
|
||||
debug!(
|
||||
?event.player_id,
|
||||
?event.target_tile,
|
||||
troops_to_send,
|
||||
?launch_tile,
|
||||
ship_id,
|
||||
"Ship launched successfully"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// System to update all ships and emit arrival events
|
||||
pub fn update_ships_system(mut ships: Query<(Entity, &mut Ship, &ChildOf)>, mut arrival_events: MessageWriter<ShipArrivalEvent>, mut commands: Commands, mut players: Query<(&NationId, &mut ShipCount)>) {
|
||||
let _guard = tracing::trace_span!("update_ships", ship_count = ships.iter().len()).entered();
|
||||
|
||||
for (ship_entity, mut ship, parent) in ships.iter_mut() {
|
||||
if ship.update() {
|
||||
// Ship has arrived at destination
|
||||
arrival_events.write(ShipArrivalEvent {
|
||||
owner_id: {
|
||||
if let Ok((nation_id, _)) = players.get(parent.0) {
|
||||
*nation_id
|
||||
} else {
|
||||
debug!(ship_id = ship.id, "Ship parent entity missing NationId");
|
||||
commands.entity(ship_entity).despawn();
|
||||
continue;
|
||||
}
|
||||
},
|
||||
target_tile: ship.target_tile,
|
||||
troops: ship.troops,
|
||||
});
|
||||
|
||||
if let Ok((nation_id, mut ship_count)) = players.get_mut(parent.0) {
|
||||
ship_count.0 = ship_count.0.saturating_sub(1);
|
||||
debug!(ship_id = ship.id, player_id = nation_id.get(), troops = ship.troops, "Ship arrived at destination");
|
||||
}
|
||||
|
||||
// Despawn ship
|
||||
commands.entity(ship_entity).despawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System to handle ship arrivals and create beachheads
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn handle_ship_arrivals_system(mut arrival_events: MessageReader<ShipArrivalEvent>, current_turn: If<Res<crate::game::CurrentTurn>>, terrain: If<Res<TerrainData>>, mut territory_manager: If<ResMut<TerritoryManager>>, mut active_attacks: If<ResMut<ActiveAttacks>>, rng: If<Res<DeterministicRng>>, entity_map: If<Res<PlayerEntityMap>>, border_cache: If<Res<crate::game::BorderCache>>, mut players: Query<(&mut Troops, &mut TerritorySize)>, mut commands: Commands) {
|
||||
let arrivals: Vec<_> = arrival_events.read().cloned().collect();
|
||||
|
||||
if arrivals.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let _guard = tracing::trace_span!("ship_arrivals", arrival_count = arrivals.len()).entered();
|
||||
|
||||
for arrival in arrivals {
|
||||
tracing::debug!(
|
||||
?arrival.owner_id,
|
||||
?arrival.target_tile,
|
||||
arrival.troops,
|
||||
"Ship arrived at destination, establishing beachhead"
|
||||
);
|
||||
|
||||
// Step 1: Force-claim the landing tile as beachhead
|
||||
let arrival_nation_id = arrival.owner_id;
|
||||
let previous_owner = territory_manager.conquer(arrival.target_tile, arrival_nation_id);
|
||||
|
||||
// Step 2: Update player stats
|
||||
if let Some(nation_id) = previous_owner
|
||||
&& let Some(&prev_entity) = (*entity_map).0.get(&nation_id)
|
||||
&& let Ok((mut troops, mut territory_size)) = players.get_mut(prev_entity)
|
||||
{
|
||||
territory_size.0 = territory_size.0.saturating_sub(1);
|
||||
if territory_size.0 == 0 {
|
||||
troops.0 = 0.0;
|
||||
commands.entity(prev_entity).insert(crate::game::Dead);
|
||||
}
|
||||
}
|
||||
if let Some(&entity) = (*entity_map).0.get(&arrival_nation_id)
|
||||
&& let Ok((_, mut territory_size)) = players.get_mut(entity)
|
||||
{
|
||||
territory_size.0 += 1;
|
||||
}
|
||||
|
||||
let turn_number = current_turn.turn.turn_number;
|
||||
let size = territory_manager.size();
|
||||
let target_tile = arrival.target_tile;
|
||||
let troops = arrival.troops;
|
||||
|
||||
// Step 3: Notify active attacks of territory change
|
||||
active_attacks.handle_territory_add(target_tile, arrival_nation_id, &territory_manager, &terrain, &rng);
|
||||
|
||||
// Step 4: Create attack from beachhead to expand
|
||||
// Find valid attack targets (not water, not our own tiles)
|
||||
let valid_targets: Vec<U16Vec2> = crate::game::utils::neighbors(target_tile, size).filter(|&neighbor| terrain.is_conquerable(neighbor) && territory_manager.get_nation_id(neighbor) != Some(arrival_nation_id)).collect();
|
||||
|
||||
// Pick a deterministic random target from valid targets
|
||||
if !valid_targets.is_empty() {
|
||||
// Deterministic random selection using turn number and beachhead position
|
||||
let seed = turn_number.wrapping_mul(31).wrapping_add(target_tile.x as u64).wrapping_add(target_tile.y as u64);
|
||||
|
||||
let index = (seed % valid_targets.len() as u64) as usize;
|
||||
let attack_target_tile = valid_targets[index];
|
||||
|
||||
// Determine the target nation (None if unclaimed)
|
||||
let attack_target = territory_manager.get_nation_id(attack_target_tile);
|
||||
|
||||
// Build player borders map for compatibility
|
||||
let player_borders = border_cache.as_map();
|
||||
let beachhead_borders = Some(&HashSet::from([target_tile]));
|
||||
|
||||
crate::game::handle_attack_internal(arrival_nation_id, attack_target, crate::game::TroopCount::Absolute(troops), false, beachhead_borders, turn_number, &territory_manager, &terrain, &mut active_attacks, &rng, &player_borders, &entity_map, &mut players, &mut commands);
|
||||
} else {
|
||||
tracing::debug!(?arrival_nation_id, ?target_tile, "Ship landed but no valid attack targets found (all adjacent tiles are water or owned)");
|
||||
}
|
||||
}
|
||||
}
|
||||
165
crates/borders-core/src/game/systems/borders.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
/// Border tile management
|
||||
///
|
||||
/// This module manages border tiles for all players. A border tile is a tile
|
||||
/// adjacent to a tile with a different owner. Borders are used for:
|
||||
/// - Attack targeting (attacks expand from border tiles)
|
||||
/// - UI rendering (show player borders on the map)
|
||||
/// - Ship launching (find coastal borders for naval operations)
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use glam::U16Vec2;
|
||||
|
||||
use crate::game::{
|
||||
entities::BorderTiles,
|
||||
utils::neighbors,
|
||||
world::{NationId, TerritoryManager},
|
||||
};
|
||||
|
||||
/// Cached border data for efficient non-ECS lookups
|
||||
///
|
||||
/// This resource caches border tiles per player to avoid reconstructing
|
||||
/// HashMaps every turn. It is updated by `update_player_borders_system`
|
||||
/// only when borders actually change.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct BorderCache {
|
||||
borders: HashMap<NationId, HashSet<U16Vec2>>,
|
||||
}
|
||||
|
||||
impl BorderCache {
|
||||
/// Get border tiles for a specific player
|
||||
#[inline]
|
||||
pub fn get(&self, player_id: NationId) -> Option<&HashSet<U16Vec2>> {
|
||||
self.borders.get(&player_id)
|
||||
}
|
||||
|
||||
/// Update the border cache with current border data
|
||||
fn update(&mut self, player_id: NationId, borders: &HashSet<U16Vec2>) {
|
||||
self.borders.insert(player_id, borders.clone());
|
||||
}
|
||||
|
||||
/// Get all player borders as a HashMap (for compatibility)
|
||||
pub fn as_map(&self) -> HashMap<NationId, &HashSet<U16Vec2>> {
|
||||
self.borders.iter().map(|(id, borders)| (*id, borders)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a border transition
|
||||
#[derive(Debug)]
|
||||
pub struct BorderTransitionResult {
|
||||
/// Tiles that became interior (not borders anymore)
|
||||
pub territory: Vec<U16Vec2>,
|
||||
/// Tiles that are now attacker borders
|
||||
pub attacker: Vec<U16Vec2>,
|
||||
/// Tiles that are now defender borders
|
||||
pub defender: Vec<U16Vec2>,
|
||||
}
|
||||
|
||||
/// Group affected tiles by their owner for efficient per-player processing
|
||||
///
|
||||
/// Instead of checking every tile for every player (O(players * tiles)),
|
||||
/// we group tiles by owner once (O(tiles)) and then process each group.
|
||||
fn group_tiles_by_owner(affected_tiles: &HashSet<U16Vec2>, territory: &TerritoryManager) -> HashMap<NationId, HashSet<U16Vec2>> {
|
||||
let _guard = tracing::trace_span!("group_tiles_by_owner", tile_count = affected_tiles.len()).entered();
|
||||
|
||||
let mut grouped: HashMap<NationId, HashSet<U16Vec2>> = HashMap::new();
|
||||
for &tile in affected_tiles {
|
||||
if let Some(nation_id) = territory.get_ownership(tile).nation_id() {
|
||||
grouped.entry(nation_id).or_default().insert(tile);
|
||||
}
|
||||
}
|
||||
|
||||
grouped
|
||||
}
|
||||
|
||||
/// System to clear territory changes
|
||||
pub fn clear_territory_changes_system(mut territory_manager: If<ResMut<TerritoryManager>>) {
|
||||
if territory_manager.has_changes() {
|
||||
tracing::trace!(count = territory_manager.iter_changes().count(), "Clearing territory changes");
|
||||
territory_manager.clear_changes();
|
||||
}
|
||||
}
|
||||
|
||||
/// Update all player borders based on territory changes (batched system)
|
||||
///
|
||||
/// This system runs once per turn AFTER all territory changes (conquests, spawns, ships).
|
||||
/// It drains the TerritoryManager's change buffer and updates borders for all affected players.
|
||||
/// It also updates the BorderCache for efficient non-ECS lookups.
|
||||
pub fn update_player_borders_system(mut players: Query<(&NationId, &mut BorderTiles)>, territory_manager: If<Res<TerritoryManager>>, mut border_cache: If<ResMut<BorderCache>>) {
|
||||
if !territory_manager.has_changes() {
|
||||
return; // Early exit - no work needed
|
||||
}
|
||||
|
||||
let _guard = tracing::trace_span!("update_player_borders").entered();
|
||||
|
||||
let (changed_tiles, raw_change_count): (HashSet<U16Vec2>, usize) = {
|
||||
let _guard = tracing::trace_span!("collect_changed_tiles").entered();
|
||||
let changes_vec: Vec<U16Vec2> = territory_manager.iter_changes().collect();
|
||||
let raw_count = changes_vec.len();
|
||||
let unique_set: HashSet<U16Vec2> = changes_vec.into_iter().collect();
|
||||
(unique_set, raw_count)
|
||||
};
|
||||
|
||||
if raw_change_count != changed_tiles.len() {
|
||||
tracing::warn!(raw_changes = raw_change_count, unique_changes = changed_tiles.len(), duplicates = raw_change_count - changed_tiles.len(), "Duplicate tile changes detected in ChangeBuffer - this causes performance degradation");
|
||||
}
|
||||
|
||||
// Build affected tiles (changed + all neighbors)
|
||||
let affected_tiles = {
|
||||
let _guard = tracing::trace_span!("build_affected_tiles", changed_count = changed_tiles.len()).entered();
|
||||
|
||||
let mut affected_tiles = HashSet::with_capacity(changed_tiles.len() * 5);
|
||||
let size = territory_manager.size();
|
||||
for &tile in &changed_tiles {
|
||||
affected_tiles.insert(tile);
|
||||
affected_tiles.extend(crate::game::core::utils::neighbors(tile, size));
|
||||
}
|
||||
affected_tiles
|
||||
};
|
||||
|
||||
// Group tiles by owner for efficient per-player processing
|
||||
let tiles_by_owner = group_tiles_by_owner(&affected_tiles, &territory_manager);
|
||||
|
||||
tracing::trace!(player_count = players.iter().len(), changed_tile_count = changed_tiles.len(), affected_tile_count = affected_tiles.len(), unique_owners = tiles_by_owner.len(), "Border update statistics");
|
||||
|
||||
// Update each player's borders (pure ECS) and BorderCache
|
||||
{
|
||||
let _guard = tracing::trace_span!("update_all_player_borders", player_count = players.iter().len()).entered();
|
||||
|
||||
for (nation_id, mut component_borders) in &mut players {
|
||||
// Only process tiles owned by this player (or empty set if none)
|
||||
let empty_set = HashSet::new();
|
||||
let player_tiles = tiles_by_owner.get(nation_id).unwrap_or(&empty_set);
|
||||
|
||||
update_borders_for_player(&mut component_borders, *nation_id, player_tiles, &territory_manager);
|
||||
|
||||
// Update the cache with the new border data
|
||||
border_cache.update(*nation_id, &component_borders);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update borders for a single player based on their owned tiles
|
||||
///
|
||||
/// Only processes tiles owned by this player, significantly reducing
|
||||
/// redundant work when multiple players exist.
|
||||
fn update_borders_for_player(borders: &mut HashSet<U16Vec2>, player_id: NationId, player_tiles: &HashSet<U16Vec2>, territory: &TerritoryManager) {
|
||||
let _guard = tracing::trace_span!(
|
||||
"update_borders_for_player",
|
||||
player_id = %player_id,
|
||||
player_tile_count = player_tiles.len(),
|
||||
current_border_count = borders.len()
|
||||
)
|
||||
.entered();
|
||||
|
||||
for &tile in player_tiles {
|
||||
// Check if it's a border (has at least one neighbor with different owner)
|
||||
let is_border = neighbors(tile, territory.size()).any(|neighbor| !territory.is_owner(neighbor, player_id));
|
||||
|
||||
if is_border {
|
||||
borders.insert(tile);
|
||||
} else {
|
||||
borders.remove(&tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
30
crates/borders-core/src/game/systems/income.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use bevy_ecs::prelude::*;
|
||||
use tracing::trace;
|
||||
|
||||
use crate::game::ai::bot::Bot;
|
||||
use crate::game::entities;
|
||||
use crate::game::{CurrentTurn, Dead, TerritorySize, Troops};
|
||||
|
||||
/// Process player income at 10 TPS (once per turn)
|
||||
/// Only runs when turn_is_ready() condition is true
|
||||
///
|
||||
/// Uses Has<Bot> to distinguish bot vs human players:
|
||||
/// - true = bot player (60% income, 33% max troops)
|
||||
/// - false = human player (100% income, 100% max troops)
|
||||
pub fn process_player_income_system(current_turn: If<Res<CurrentTurn>>, mut players: Query<(&mut Troops, &TerritorySize, Has<Bot>), Without<Dead>>) {
|
||||
// Skip income processing on Turn 0 - players haven't spawned yet
|
||||
// Spawning happens during execute_turn_gameplay_system on Turn 0
|
||||
if current_turn.turn.turn_number == 0 {
|
||||
trace!("Skipping income on Turn 0 (pre-spawn)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Process income for all alive players (Without<Dead> filter)
|
||||
for (mut troops, territory_size, is_bot) in &mut players {
|
||||
// Calculate and apply income
|
||||
let income = entities::calculate_income(troops.0, territory_size.0, is_bot);
|
||||
troops.0 = entities::add_troops_capped(troops.0, income, territory_size.0, is_bot);
|
||||
}
|
||||
|
||||
trace!("Income processed for turn {}", current_turn.turn.turn_number);
|
||||
}
|
||||
18
crates/borders-core/src/game/systems/mod.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
//! Game systems that run each tick/turn
|
||||
//!
|
||||
//! This module contains systems that execute game logic.
|
||||
|
||||
pub mod borders;
|
||||
pub mod income;
|
||||
pub mod spawn;
|
||||
pub mod spawn_territory;
|
||||
pub mod spawn_timeout;
|
||||
pub mod turn;
|
||||
|
||||
// Re-export system functions and types
|
||||
pub use borders::*;
|
||||
pub use income::*;
|
||||
pub use spawn::*;
|
||||
pub use spawn_territory::*;
|
||||
pub use spawn_timeout::*;
|
||||
pub use turn::*;
|
||||
80
crates/borders-core/src/game/systems/spawn.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::game::NationId;
|
||||
|
||||
/// Represents a spawn point for a player or bot
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SpawnPoint {
|
||||
pub nation: NationId,
|
||||
pub tile: glam::U16Vec2,
|
||||
}
|
||||
|
||||
impl SpawnPoint {
|
||||
pub fn new(nation: NationId, tile: glam::U16Vec2) -> Self {
|
||||
Self { nation, tile }
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages spawn positions during the pre-game spawn phase
|
||||
///
|
||||
/// This resource tracks bot and player spawn positions before the game starts ticking.
|
||||
/// It allows for dynamic recalculation of bot positions when players change their spawn
|
||||
/// location, implementing the two-pass spawn system described in the README.
|
||||
#[derive(Resource)]
|
||||
pub struct SpawnManager {
|
||||
/// Initial bot spawn positions from first pass
|
||||
pub initial_bot_spawns: Vec<SpawnPoint>,
|
||||
|
||||
/// Current bot spawn positions after recalculation
|
||||
/// These are updated whenever a player chooses/changes their spawn
|
||||
pub current_bot_spawns: Vec<SpawnPoint>,
|
||||
|
||||
/// Player spawn positions
|
||||
/// Tracks human player spawn selections
|
||||
pub player_spawns: Vec<SpawnPoint>,
|
||||
|
||||
/// RNG seed for deterministic spawn calculations
|
||||
pub rng_seed: u64,
|
||||
}
|
||||
|
||||
impl SpawnManager {
|
||||
/// Create a new SpawnManager with initial bot spawns
|
||||
pub fn new(initial_bot_spawns: Vec<SpawnPoint>, rng_seed: u64) -> Self {
|
||||
Self { current_bot_spawns: initial_bot_spawns.clone(), initial_bot_spawns, player_spawns: Vec::new(), rng_seed }
|
||||
}
|
||||
|
||||
/// Update a player's spawn position and recalculate bot spawns if necessary
|
||||
///
|
||||
/// This triggers the second pass of the two-pass spawn system, relocating
|
||||
/// any bots that are too close to the new player position.
|
||||
pub fn update_player_spawn(&mut self, player_id: NationId, tile_index: glam::U16Vec2, territory_manager: &crate::game::TerritoryManager, terrain: &crate::game::terrain::TerrainData) {
|
||||
let spawn_point = SpawnPoint::new(player_id, tile_index);
|
||||
|
||||
// Update or add player spawn
|
||||
if let Some(entry) = self.player_spawns.iter_mut().find(|spawn| spawn.nation == player_id) {
|
||||
*entry = spawn_point;
|
||||
} else {
|
||||
self.player_spawns.push(spawn_point);
|
||||
}
|
||||
|
||||
// Recalculate bot spawns with updated player positions
|
||||
self.current_bot_spawns = crate::game::ai::bot::recalculate_spawns_with_players(self.initial_bot_spawns.clone(), &self.player_spawns, territory_manager, terrain, self.rng_seed);
|
||||
}
|
||||
|
||||
/// Get all current spawn positions (players + bots)
|
||||
pub fn get_all_spawns(&self) -> Vec<SpawnPoint> {
|
||||
let mut all_spawns = self.player_spawns.clone();
|
||||
all_spawns.extend(self.current_bot_spawns.iter().copied());
|
||||
all_spawns
|
||||
}
|
||||
|
||||
/// Get only bot spawn positions
|
||||
pub fn get_bot_spawns(&self) -> &[SpawnPoint] {
|
||||
&self.current_bot_spawns
|
||||
}
|
||||
|
||||
/// Get only player spawn positions
|
||||
pub fn get_player_spawns(&self) -> &[SpawnPoint] {
|
||||
&self.player_spawns
|
||||
}
|
||||
}
|
||||
119
crates/borders-core/src/game/systems/spawn_territory.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
//! Spawn territory claiming logic
|
||||
//!
|
||||
//! Provides utilities for claiming 5x5 territories around spawn points.
|
||||
|
||||
use glam::U16Vec2;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::game::terrain::data::TerrainData;
|
||||
use crate::game::world::{NationId, TileOwnership};
|
||||
|
||||
/// Claims a 5x5 territory around a spawn point
|
||||
///
|
||||
/// Claims all unclaimed, conquerable tiles within 2 tiles of the spawn center
|
||||
/// and returns the set of tiles that were successfully claimed.
|
||||
#[inline]
|
||||
pub fn claim_spawn_territory(spawn_center: U16Vec2, nation: NationId, territories: &mut [TileOwnership], terrain: &TerrainData, map_size: U16Vec2) -> HashSet<U16Vec2> {
|
||||
let width = map_size.x as usize;
|
||||
|
||||
(-2..=2)
|
||||
.flat_map(|dy| (-2..=2).map(move |dx| (dx, dy)))
|
||||
.filter_map(|(dx, dy)| {
|
||||
let x = (spawn_center.x as i32 + dx).clamp(0, map_size.x as i32 - 1) as usize;
|
||||
let y = (spawn_center.y as i32 + dy).clamp(0, map_size.y as i32 - 1) as usize;
|
||||
let tile_pos = U16Vec2::new(x as u16, y as u16);
|
||||
let idx = y * width + x;
|
||||
|
||||
if territories[idx].is_unclaimed() && terrain.is_conquerable(tile_pos) {
|
||||
territories[idx] = TileOwnership::Owned(nation);
|
||||
Some(tile_pos)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Clears spawn territory for a specific nation within a 5x5 area
|
||||
///
|
||||
/// Reverts all tiles owned by the given nation within the 5x5 area back to unclaimed
|
||||
/// and returns the set of tiles that were cleared.
|
||||
#[inline]
|
||||
pub fn clear_spawn_territory(spawn_center: U16Vec2, nation: NationId, territories: &mut [TileOwnership], map_size: U16Vec2) -> HashSet<U16Vec2> {
|
||||
let width = map_size.x as usize;
|
||||
|
||||
(-2..=2)
|
||||
.flat_map(|dy| (-2..=2).map(move |dx| (dx, dy)))
|
||||
.filter_map(|(dx, dy)| {
|
||||
let x = (spawn_center.x as i32 + dx).clamp(0, map_size.x as i32 - 1) as usize;
|
||||
let y = (spawn_center.y as i32 + dy).clamp(0, map_size.y as i32 - 1) as usize;
|
||||
let tile_pos = U16Vec2::new(x as u16, y as u16);
|
||||
let idx = y * width + x;
|
||||
|
||||
if territories[idx].is_owned_by(nation) {
|
||||
territories[idx] = TileOwnership::Unclaimed;
|
||||
Some(tile_pos)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game::terrain::data::TerrainData;
|
||||
|
||||
#[test]
|
||||
fn test_claim_spawn_territory_basic() {
|
||||
let map_size = U16Vec2::new(10, 10);
|
||||
let mut territories = vec![TileOwnership::Unclaimed; 100];
|
||||
|
||||
// Mock terrain - all conquerable
|
||||
let terrain_data = vec![0x80u8; 100]; // bit 7 = conquerable
|
||||
let terrain = TerrainData { _manifest: crate::game::terrain::data::MapManifest { name: "Test".to_string(), map: crate::game::terrain::data::MapMetadata { size: map_size, num_land_tiles: 100 }, nations: vec![] }, terrain_data: crate::game::world::tilemap::TileMap::from_vec(10, 10, terrain_data), tiles: vec![0; 100], tile_types: vec![crate::game::terrain::data::TileType { name: "land".to_string(), color_base: "grass".to_string(), color_variant: 0, conquerable: true, navigable: false, expansion_cost: 50, expansion_time: 50 }] };
|
||||
|
||||
let nation = NationId::new(0).unwrap();
|
||||
let spawn_center = U16Vec2::new(5, 5);
|
||||
|
||||
let claimed = claim_spawn_territory(spawn_center, nation, &mut territories, &terrain, map_size);
|
||||
|
||||
// Should claim 5x5 = 25 tiles (all within bounds and conquerable)
|
||||
assert_eq!(claimed.len(), 25);
|
||||
|
||||
// Verify the center tile is claimed
|
||||
let center_idx = 5 * 10 + 5;
|
||||
assert!(territories[center_idx].is_owned_by(nation));
|
||||
|
||||
// Verify corners are claimed
|
||||
let corner_idx = 3 * 10 + 3; // top-left corner
|
||||
assert!(territories[corner_idx].is_owned_by(nation));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_spawn_territory() {
|
||||
let map_size = U16Vec2::new(10, 10);
|
||||
let mut territories = vec![TileOwnership::Unclaimed; 100];
|
||||
|
||||
let nation = NationId::new(0).unwrap();
|
||||
let spawn_center = U16Vec2::new(5, 5);
|
||||
|
||||
// First claim territory
|
||||
for y in 3..=7 {
|
||||
for x in 3..=7 {
|
||||
let idx = y * 10 + x;
|
||||
territories[idx] = TileOwnership::Owned(nation);
|
||||
}
|
||||
}
|
||||
|
||||
// Now clear it
|
||||
let cleared = clear_spawn_territory(spawn_center, nation, &mut territories, map_size);
|
||||
|
||||
assert_eq!(cleared.len(), 25);
|
||||
|
||||
// Verify center is unclaimed
|
||||
let center_idx = 5 * 10 + 5;
|
||||
assert!(territories[center_idx].is_unclaimed());
|
||||
}
|
||||
}
|
||||
75
crates/borders-core/src/game/systems/spawn_timeout.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
/// Tracks spawn phase timeout state on the client side
|
||||
///
|
||||
/// This resource is used to:
|
||||
/// - Show countdown timer in UI
|
||||
/// - Know when spawn phase is active
|
||||
/// - Calculate remaining time for display
|
||||
#[derive(Resource)]
|
||||
pub struct SpawnTimeout {
|
||||
/// Whether spawn phase is currently active
|
||||
pub active: bool,
|
||||
|
||||
/// Accumulated time since start (seconds)
|
||||
pub elapsed_secs: f32,
|
||||
|
||||
/// Total timeout duration in seconds
|
||||
pub duration_secs: f32,
|
||||
|
||||
/// Remaining time in seconds (updated each frame)
|
||||
pub remaining_secs: f32,
|
||||
}
|
||||
|
||||
impl Default for SpawnTimeout {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
active: false,
|
||||
elapsed_secs: 0.0,
|
||||
duration_secs: 5.0, // Local mode: 5 seconds
|
||||
remaining_secs: 5.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SpawnTimeout {
|
||||
/// Create a new spawn timeout with specified duration
|
||||
pub fn new(duration_secs: f32) -> Self {
|
||||
Self { active: false, elapsed_secs: 0.0, duration_secs, remaining_secs: duration_secs }
|
||||
}
|
||||
|
||||
/// Start the timeout countdown
|
||||
pub fn start(&mut self) {
|
||||
if self.elapsed_secs == 0.0 {
|
||||
self.active = true;
|
||||
self.elapsed_secs = 0.0;
|
||||
self.remaining_secs = self.duration_secs;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update remaining time (call each frame with delta time)
|
||||
pub fn update(&mut self, delta_secs: f32) {
|
||||
if !self.active {
|
||||
return;
|
||||
}
|
||||
|
||||
self.elapsed_secs += delta_secs;
|
||||
self.remaining_secs = (self.duration_secs - self.elapsed_secs).max(0.0);
|
||||
|
||||
if self.remaining_secs <= 0.0 {
|
||||
self.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the timeout
|
||||
pub fn stop(&mut self) {
|
||||
self.active = false;
|
||||
self.elapsed_secs = 0.0;
|
||||
}
|
||||
|
||||
/// Check if timeout has expired
|
||||
#[inline]
|
||||
pub fn has_expired(&self) -> bool {
|
||||
!self.active && self.remaining_secs <= 0.0
|
||||
}
|
||||
}
|
||||
36
crates/borders-core/src/game/systems/turn.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::networking::Turn;
|
||||
|
||||
/// Resource containing the current turn data
|
||||
/// Updated once per turn (10 TPS), provides turn context to all gameplay systems
|
||||
#[derive(Resource)]
|
||||
pub struct CurrentTurn {
|
||||
pub turn: Turn,
|
||||
/// Flag indicating if this turn has been processed by gameplay systems
|
||||
/// Set to false when turn arrives, set to true after all systems run
|
||||
pub processed: bool,
|
||||
}
|
||||
|
||||
impl CurrentTurn {
|
||||
pub fn new(turn: Turn) -> Self {
|
||||
Self { turn, processed: false }
|
||||
}
|
||||
|
||||
/// Mark turn as processed
|
||||
#[inline]
|
||||
pub fn mark_processed(&mut self) {
|
||||
self.processed = true;
|
||||
}
|
||||
|
||||
/// Check if turn is ready to process (not yet processed)
|
||||
#[inline]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
!self.processed
|
||||
}
|
||||
}
|
||||
|
||||
/// Run condition: only run when a turn is ready to process
|
||||
pub fn turn_is_ready(current_turn: Option<Res<CurrentTurn>>) -> bool {
|
||||
current_turn.is_some_and(|ct| ct.is_ready())
|
||||
}
|
||||
124
crates/borders-core/src/game/terrain/connectivity.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use crate::game::NationId;
|
||||
use crate::game::TileOwnership;
|
||||
use crate::game::terrain::data::TerrainData;
|
||||
use crate::game::utils::neighbors;
|
||||
use glam::U16Vec2;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Check if a target tile's region connects to any of the player's tiles
|
||||
/// Uses flood-fill through tiles matching the target's ownership
|
||||
/// Returns true if connected (normal attack), false if disconnected (ship needed)
|
||||
pub fn is_connected_to_player(territory: &[TileOwnership], terrain: &TerrainData, target_tile: U16Vec2, player_id: NationId, size: U16Vec2) -> bool {
|
||||
let target_idx = (target_tile.y as usize) * (size.x as usize) + (target_tile.x as usize);
|
||||
let target_ownership = territory[target_idx];
|
||||
|
||||
// Can't connect to water
|
||||
if terrain.is_navigable(target_tile) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If target is owned by player, it's already connected
|
||||
if target_ownership.is_owned_by(player_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Flood-fill from target through tiles with same ownership
|
||||
let mut queue = VecDeque::new();
|
||||
let mut visited = vec![false; (size.x as usize) * (size.y as usize)];
|
||||
|
||||
queue.push_back(target_tile);
|
||||
visited[target_idx] = true;
|
||||
|
||||
while let Some(current_pos) = queue.pop_front() {
|
||||
for neighbor_pos in neighbors(current_pos, size) {
|
||||
let neighbor_idx = (neighbor_pos.y as usize) * (size.x as usize) + (neighbor_pos.x as usize);
|
||||
|
||||
if visited[neighbor_idx] {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Don't cross water - only flood-fill through land on the same landmass
|
||||
if terrain.is_navigable(neighbor_pos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let neighbor_ownership = territory[neighbor_idx];
|
||||
|
||||
// Check if we found a player tile - SUCCESS!
|
||||
if neighbor_ownership.is_owned_by(player_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only continue through tiles matching target's ownership
|
||||
if neighbor_ownership == target_ownership {
|
||||
visited[neighbor_idx] = true;
|
||||
queue.push_back(neighbor_pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exhausted search without finding player tile
|
||||
false
|
||||
}
|
||||
|
||||
/// Find the nearest coastal tile in a region by flood-filling from target
|
||||
/// Only expands through tiles matching the target's ownership
|
||||
/// Returns coastal tile position if found
|
||||
pub fn find_coastal_tile_in_region(territory: &[TileOwnership], terrain: &TerrainData, target_tile: U16Vec2, size: U16Vec2) -> Option<U16Vec2> {
|
||||
let target_idx = (target_tile.y as usize) * (size.x as usize) + (target_tile.x as usize);
|
||||
let target_ownership = territory[target_idx];
|
||||
|
||||
// Can't find coastal tile in water
|
||||
if terrain.is_navigable(target_tile) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check if target itself is coastal
|
||||
if is_coastal_tile(terrain, target_tile, size) {
|
||||
return Some(target_tile);
|
||||
}
|
||||
|
||||
// BFS from target through same-ownership tiles
|
||||
let mut queue = VecDeque::new();
|
||||
let mut visited = vec![false; (size.x as usize) * (size.y as usize)];
|
||||
|
||||
queue.push_back(target_tile);
|
||||
visited[target_idx] = true;
|
||||
|
||||
while let Some(current_pos) = queue.pop_front() {
|
||||
for neighbor_pos in neighbors(current_pos, size) {
|
||||
let neighbor_idx = (neighbor_pos.y as usize) * (size.x as usize) + (neighbor_pos.x as usize);
|
||||
|
||||
if visited[neighbor_idx] {
|
||||
continue;
|
||||
}
|
||||
|
||||
let neighbor_ownership = territory[neighbor_idx];
|
||||
|
||||
// Only expand through matching ownership
|
||||
if neighbor_ownership == target_ownership {
|
||||
visited[neighbor_idx] = true;
|
||||
|
||||
// Check if this tile is coastal
|
||||
if is_coastal_tile(terrain, neighbor_pos, size) {
|
||||
return Some(neighbor_pos);
|
||||
}
|
||||
|
||||
queue.push_back(neighbor_pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if a tile is coastal (land tile adjacent to water)
|
||||
pub fn is_coastal_tile(terrain: &TerrainData, tile: U16Vec2, size: U16Vec2) -> bool {
|
||||
// Must be land tile
|
||||
if terrain.is_navigable(tile) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if any neighbor is water (4-directional)
|
||||
neighbors(tile, size).any(|neighbor| terrain.is_navigable(neighbor))
|
||||
}
|
||||
250
crates/borders-core/src/game/terrain/data.rs
Normal file
@@ -0,0 +1,250 @@
|
||||
use bevy_ecs::prelude::Resource;
|
||||
use glam::U16Vec2;
|
||||
use image::GenericImageView;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
|
||||
use crate::game::world::tilemap::TileMap;
|
||||
|
||||
/// Calculate terrain color using pastel theme formulas
|
||||
fn calculate_theme_color(color_base: &str, color_variant: u8) -> [u8; 3] {
|
||||
let i = color_variant as i32;
|
||||
|
||||
match color_base {
|
||||
"grass" => {
|
||||
// rgb(238 - 2 * i, 238 - 2 * i, 190 - i)
|
||||
[(238 - 2 * i).clamp(0, 255) as u8, (238 - 2 * i).clamp(0, 255) as u8, (190 - i).clamp(0, 255) as u8]
|
||||
}
|
||||
"mountain" => {
|
||||
// rgb(250 - 2 * i, 250 - 2 * i, 220 - i)
|
||||
[(250 - 2 * i).clamp(0, 255) as u8, (250 - 2 * i).clamp(0, 255) as u8, (220 - i).clamp(0, 255) as u8]
|
||||
}
|
||||
"water" => {
|
||||
// rgb(172 - 2 * i, 225 - 2 * i, 249 - 3 * i)
|
||||
[(172 - 2 * i).clamp(0, 255) as u8, (225 - 2 * i).clamp(0, 255) as u8, (249 - 3 * i).clamp(0, 255) as u8]
|
||||
}
|
||||
_ => {
|
||||
// Default fallback color (gray)
|
||||
[128, 128, 128]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper structs for loading World.json format
|
||||
#[derive(Deserialize)]
|
||||
struct WorldMapJson {
|
||||
tiles: Vec<WorldTileDef>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WorldTileDef {
|
||||
color: String,
|
||||
name: String,
|
||||
#[serde(default, rename = "colorBase")]
|
||||
color_base: Option<String>,
|
||||
#[serde(default, rename = "colorVariant")]
|
||||
color_variant: Option<u32>,
|
||||
conquerable: bool,
|
||||
navigable: bool,
|
||||
#[serde(default, rename = "expansionCost")]
|
||||
expansion_cost: Option<u32>,
|
||||
#[serde(default, rename = "expansionTime")]
|
||||
expansion_time: Option<u32>,
|
||||
}
|
||||
|
||||
/// Parse hex color string (#RRGGBB) to RGB bytes
|
||||
fn parse_hex_rgb(s: &str) -> Option<[u8; 3]> {
|
||||
let s = s.trim_start_matches('#');
|
||||
if s.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
|
||||
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
|
||||
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
|
||||
Some([r, g, b])
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TileType {
|
||||
pub name: String,
|
||||
pub color_base: String,
|
||||
pub color_variant: u8,
|
||||
pub conquerable: bool,
|
||||
pub navigable: bool,
|
||||
pub expansion_time: u8,
|
||||
pub expansion_cost: u8,
|
||||
}
|
||||
|
||||
/// Map manifest structure
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct MapManifest {
|
||||
pub map: MapMetadata,
|
||||
pub name: String,
|
||||
pub nations: Vec<NationSpawn>,
|
||||
}
|
||||
|
||||
/// Map size metadata
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct MapMetadata {
|
||||
pub size: glam::U16Vec2,
|
||||
pub num_land_tiles: usize,
|
||||
}
|
||||
|
||||
impl MapMetadata {
|
||||
/// Get the width of the map
|
||||
#[inline]
|
||||
pub fn width(&self) -> u16 {
|
||||
self.size.x
|
||||
}
|
||||
|
||||
/// Get the height of the map
|
||||
#[inline]
|
||||
pub fn height(&self) -> u16 {
|
||||
self.size.y
|
||||
}
|
||||
}
|
||||
|
||||
/// Nation spawn point
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct NationSpawn {
|
||||
pub coordinates: [usize; 2],
|
||||
pub flag: String,
|
||||
pub name: String,
|
||||
pub strength: u32,
|
||||
}
|
||||
|
||||
/// Loaded map data
|
||||
#[derive(Debug, Clone, Resource)]
|
||||
pub struct TerrainData {
|
||||
pub _manifest: MapManifest,
|
||||
/// Legacy terrain data (for backward compatibility)
|
||||
pub terrain_data: TileMap<u8>,
|
||||
/// Tile type indices (new format)
|
||||
pub tiles: Vec<u8>,
|
||||
/// Tile type definitions
|
||||
pub tile_types: Vec<TileType>,
|
||||
}
|
||||
|
||||
impl TerrainData {
|
||||
/// Load the World map from embedded assets
|
||||
pub fn load_world_map() -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let _guard = tracing::debug_span!("load_world_map").entered();
|
||||
|
||||
const MAP_JSON: &[u8] = include_bytes!("../../../assets/maps/World.json");
|
||||
const MAP_PNG: &[u8] = include_bytes!("../../../assets/maps/World.png");
|
||||
|
||||
// Parse JSON tile definitions
|
||||
let map_json: WorldMapJson = {
|
||||
let _guard = tracing::trace_span!("parse_json").entered();
|
||||
serde_json::from_slice(MAP_JSON)?
|
||||
};
|
||||
|
||||
// Load PNG image
|
||||
let (png, width, height) = {
|
||||
let _guard = tracing::trace_span!("load_png").entered();
|
||||
let png = image::load_from_memory(MAP_PNG)?;
|
||||
let (width, height) = png.dimensions();
|
||||
(png, width, height)
|
||||
};
|
||||
|
||||
info!("Loading World map: {}x{}", width, height);
|
||||
|
||||
// Build color-to-index lookup table
|
||||
let color_to_index: Vec<([u8; 3], usize)> = map_json.tiles.iter().enumerate().filter_map(|(idx, t)| parse_hex_rgb(&t.color).map(|rgb| (rgb, idx))).collect();
|
||||
|
||||
let pixel_count = (width as usize) * (height as usize);
|
||||
let mut tiles = vec![0u8; pixel_count];
|
||||
let mut terrain_data_raw = vec![0u8; pixel_count];
|
||||
|
||||
// Match each pixel to nearest tile type by color
|
||||
{
|
||||
let _guard = tracing::trace_span!("pixel_processing", pixel_count = pixel_count).entered();
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let pixel = png.get_pixel(x, y).0;
|
||||
let rgb = [pixel[0], pixel[1], pixel[2]];
|
||||
|
||||
// Find nearest tile by RGB distance
|
||||
let (tile_idx, _) = color_to_index
|
||||
.iter()
|
||||
.map(|(c, idx)| {
|
||||
let dr = rgb[0] as i32 - c[0] as i32;
|
||||
let dg = rgb[1] as i32 - c[1] as i32;
|
||||
let db = rgb[2] as i32 - c[2] as i32;
|
||||
let dist = (dr * dr + dg * dg + db * db) as u32;
|
||||
(idx, dist)
|
||||
})
|
||||
.min_by_key(|(_, d)| *d)
|
||||
.unwrap();
|
||||
|
||||
let i = (y * width + x) as usize;
|
||||
tiles[i] = *tile_idx as u8;
|
||||
|
||||
// Set bit 7 if conquerable (land)
|
||||
if map_json.tiles[*tile_idx].conquerable {
|
||||
terrain_data_raw[i] |= 0x80;
|
||||
}
|
||||
// Lower 5 bits for terrain magnitude (unused for World map)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to TileType format
|
||||
let tile_types = {
|
||||
let _guard = tracing::trace_span!("tile_type_conversion").entered();
|
||||
map_json.tiles.into_iter().map(|t| TileType { name: t.name, color_base: t.color_base.unwrap_or_default(), color_variant: t.color_variant.unwrap_or(0) as u8, conquerable: t.conquerable, navigable: t.navigable, expansion_cost: t.expansion_cost.unwrap_or(50) as u8, expansion_time: t.expansion_time.unwrap_or(50) as u8 }).collect()
|
||||
};
|
||||
|
||||
let num_land_tiles = terrain_data_raw.iter().filter(|&&b| b & 0x80 != 0).count();
|
||||
|
||||
info!("World map loaded: {} land tiles", num_land_tiles);
|
||||
|
||||
Ok(Self { _manifest: MapManifest { name: "World".to_string(), map: MapMetadata { size: glam::U16Vec2::new(width as u16, height as u16), num_land_tiles }, nations: vec![] }, terrain_data: TileMap::from_vec(width as u16, height as u16, terrain_data_raw), tiles, tile_types })
|
||||
}
|
||||
|
||||
/// Get the size of the map
|
||||
#[inline]
|
||||
pub fn size(&self) -> U16Vec2 {
|
||||
self.terrain_data.size()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn get_value(&self, pos: U16Vec2) -> u8 {
|
||||
self.terrain_data[pos]
|
||||
}
|
||||
|
||||
/// Check if a tile is land (bit 7 set)
|
||||
#[inline]
|
||||
pub fn is_land(&self, pos: U16Vec2) -> bool {
|
||||
self.get_value(pos) & 0x80 != 0
|
||||
}
|
||||
|
||||
/// Get tile type at position
|
||||
pub fn get_tile_type(&self, pos: U16Vec2) -> &TileType {
|
||||
let idx = self.terrain_data.pos_to_index(pos) as usize;
|
||||
&self.tile_types[self.tiles[idx] as usize]
|
||||
}
|
||||
|
||||
/// Check if a tile is conquerable
|
||||
pub fn is_conquerable(&self, pos: U16Vec2) -> bool {
|
||||
self.get_tile_type(pos).conquerable
|
||||
}
|
||||
|
||||
/// Check if a tile is navigable (water)
|
||||
pub fn is_navigable(&self, pos: U16Vec2) -> bool {
|
||||
self.get_tile_type(pos).navigable
|
||||
}
|
||||
|
||||
/// Get tile type IDs for rendering (each position maps to a tile type)
|
||||
pub fn get_tile_ids(&self) -> &[u8] {
|
||||
&self.tiles
|
||||
}
|
||||
|
||||
/// Get terrain palette colors from tile types (for rendering)
|
||||
/// Returns a vec where index = tile type ID, value = RGB color
|
||||
/// Colors are calculated using theme formulas based on colorBase and colorVariant
|
||||
pub fn get_terrain_palette_colors(&self) -> Vec<[u8; 3]> {
|
||||
self.tile_types.iter().map(|tile_type| calculate_theme_color(&tile_type.color_base, tile_type.color_variant)).collect()
|
||||
}
|
||||
}
|
||||
9
crates/borders-core/src/game/terrain/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
//! Terrain and map connectivity
|
||||
//!
|
||||
//! This module handles terrain data and pathfinding/connectivity analysis.
|
||||
|
||||
pub mod connectivity;
|
||||
pub mod data;
|
||||
|
||||
pub use connectivity::*;
|
||||
pub use data::*;
|
||||
109
crates/borders-core/src/game/view.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
//! Game state view for rendering and UI
|
||||
//!
|
||||
//! This module provides read-only snapshots of game state that are:
|
||||
//! - Deterministic and identical across all clients
|
||||
//! - Safe for use in rendering and input systems
|
||||
//! - Serializable for network transmission
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bevy_ecs::prelude::Resource;
|
||||
use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::game::NationId;
|
||||
|
||||
/// Read-only snapshot of game state for rendering - DETERMINISTIC, SHARED
|
||||
///
|
||||
/// **Important: This is GLOBAL/SHARED state identical across all clients!**
|
||||
///
|
||||
/// This is a read-only snapshot of game state (TerritoryManager, player stats, etc.), updated after each turn.
|
||||
/// It provides:
|
||||
/// - Safe, immutable access to game state for rendering and input systems
|
||||
/// - Serializable format for network transmission
|
||||
/// - Same view for all clients (server, players, spectators)
|
||||
///
|
||||
/// Systems should prefer using GameView over direct access to game resources
|
||||
/// to maintain clean separation between game logic and rendering/input.
|
||||
#[derive(Resource, Default, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GameView {
|
||||
pub size: glam::U16Vec2,
|
||||
/// Owner of each tile. Uses Arc for zero-copy sharing with rendering.
|
||||
pub territories: Arc<[crate::game::TileOwnership]>,
|
||||
pub players: Vec<PlayerView>,
|
||||
pub turn_number: u64,
|
||||
/// Total number of conquerable (non-water) tiles on the map.
|
||||
/// Cached for performance - calculated once at initialization.
|
||||
pub total_land_tiles: u32,
|
||||
/// Indices of tiles that changed ownership this turn (from TerritoryManager's ChangeBuffer).
|
||||
/// Used for efficient delta rendering without full map scans.
|
||||
/// Uses u32 for JavaScript compatibility at the API boundary.
|
||||
pub changed_tiles: Vec<u32>,
|
||||
/// Active ships on the map
|
||||
pub ships: Vec<ShipView>,
|
||||
}
|
||||
|
||||
impl GameView {
|
||||
/// Get the size of the map as U16Vec2
|
||||
#[inline]
|
||||
pub fn size(&self) -> glam::U16Vec2 {
|
||||
self.size
|
||||
}
|
||||
|
||||
/// Get the width of the map
|
||||
#[inline]
|
||||
pub fn width(&self) -> u16 {
|
||||
self.size.x
|
||||
}
|
||||
|
||||
/// Get the height of the map
|
||||
#[inline]
|
||||
pub fn height(&self) -> u16 {
|
||||
self.size.y
|
||||
}
|
||||
|
||||
/// Get the ownership of a specific tile (accepts u32 for JS compatibility)
|
||||
pub fn get_ownership(&self, tile_index: u32) -> crate::game::TileOwnership {
|
||||
self.territories.get(tile_index as usize).copied().unwrap_or(crate::game::TileOwnership::Unclaimed)
|
||||
}
|
||||
|
||||
/// Get the owner of a specific tile as u16 for IPC/serialization (accepts u32 for JS compatibility)
|
||||
pub fn get_owner_u16(&self, tile_index: u32) -> u16 {
|
||||
self.get_ownership(tile_index).into()
|
||||
}
|
||||
|
||||
/// Get a player by ID
|
||||
pub fn get_nation_id(&self, id: NationId) -> Option<&PlayerView> {
|
||||
self.players.iter().find(|player| player.id == id)
|
||||
}
|
||||
|
||||
/// Find any tile owned by a specific player (useful for camera centering)
|
||||
/// Returns u32 for JavaScript compatibility
|
||||
pub fn find_tile_owned_by(&self, player_id: NationId) -> Option<u32> {
|
||||
self.territories.iter().position(|ownership| ownership.is_owned_by(player_id)).map(|idx| idx as u32)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Archive, RkyvSerialize, RkyvDeserialize)]
|
||||
#[rkyv(derive(Debug))]
|
||||
pub struct PlayerView {
|
||||
pub id: NationId,
|
||||
pub color: [f32; 4],
|
||||
pub name: String,
|
||||
pub tile_count: u32,
|
||||
pub troops: u32,
|
||||
pub is_alive: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Archive, RkyvSerialize, RkyvDeserialize)]
|
||||
#[rkyv(derive(Debug))]
|
||||
pub struct ShipView {
|
||||
pub id: u32,
|
||||
pub owner_id: NationId,
|
||||
pub current_tile: u32,
|
||||
pub target_tile: u32,
|
||||
pub troops: u32,
|
||||
pub path_progress: u32,
|
||||
pub ticks_until_move: u32,
|
||||
pub path: Vec<u32>,
|
||||
}
|
||||
184
crates/borders-core/src/game/world/changes.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use glam::U16Vec2;
|
||||
|
||||
/// Lightweight change tracking buffer for tile mutations.
|
||||
///
|
||||
/// Stores only the indices of changed tiles, using a HashSet to automatically
|
||||
/// deduplicate when the same tile changes multiple times per turn. This enables
|
||||
/// efficient delta updates for GPU rendering and network synchronization.
|
||||
///
|
||||
/// # Design
|
||||
/// - Records tile index changes as they occur
|
||||
/// - Automatically deduplicates tile indices
|
||||
/// - O(1) average insert, O(changes) iteration
|
||||
/// - Optional: can be cleared/ignored when tracking not needed
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use borders_core::game::ChangeBuffer;
|
||||
///
|
||||
/// let mut changes = ChangeBuffer::new();
|
||||
/// changes.push(10);
|
||||
/// changes.push(25);
|
||||
/// assert_eq!(changes.len(), 2);
|
||||
///
|
||||
/// let indices: Vec<_> = changes.drain().collect();
|
||||
/// assert_eq!(indices.len(), 2);
|
||||
/// assert_eq!(changes.len(), 0);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChangeBuffer {
|
||||
changed_indices: HashSet<U16Vec2>,
|
||||
}
|
||||
|
||||
impl ChangeBuffer {
|
||||
/// Creates a new empty ChangeBuffer.
|
||||
pub fn new() -> Self {
|
||||
Self { changed_indices: HashSet::new() }
|
||||
}
|
||||
|
||||
/// Creates a new ChangeBuffer with pre-allocated capacity.
|
||||
///
|
||||
/// Use this when you know the approximate number of changes to avoid reallocations.
|
||||
pub fn with_capacity(capacity: usize) -> Self {
|
||||
Self { changed_indices: HashSet::with_capacity(capacity) }
|
||||
}
|
||||
|
||||
/// Records a tile index as changed.
|
||||
///
|
||||
/// Automatically deduplicates - pushing the same index multiple times
|
||||
/// only records it once. This is O(1) average case.
|
||||
#[inline]
|
||||
pub fn push(&mut self, position: U16Vec2) {
|
||||
self.changed_indices.insert(position);
|
||||
}
|
||||
|
||||
/// Returns an iterator over changed indices without consuming them.
|
||||
///
|
||||
/// Use this when you need to read changes without clearing the buffer.
|
||||
/// The buffer will still contain all changes after iteration.
|
||||
pub fn iter(&self) -> impl Iterator<Item = U16Vec2> + '_ {
|
||||
self.changed_indices.iter().copied()
|
||||
}
|
||||
|
||||
/// Drains all changed indices, returning an iterator and clearing the buffer.
|
||||
///
|
||||
/// The buffer retains its capacity for reuse.
|
||||
pub fn drain(&mut self) -> impl Iterator<Item = U16Vec2> + '_ {
|
||||
self.changed_indices.drain()
|
||||
}
|
||||
|
||||
/// Clears all tracked changes without returning them.
|
||||
///
|
||||
/// The buffer retains its capacity for reuse.
|
||||
pub fn clear(&mut self) {
|
||||
self.changed_indices.clear();
|
||||
}
|
||||
|
||||
/// Returns true if any changes have been recorded.
|
||||
#[inline]
|
||||
pub fn has_changes(&self) -> bool {
|
||||
!self.changed_indices.is_empty()
|
||||
}
|
||||
|
||||
/// Returns the number of changes recorded.
|
||||
///
|
||||
/// Note: This may include duplicate indices if the same tile was changed multiple times.
|
||||
#[inline]
|
||||
pub fn len(&self) -> usize {
|
||||
self.changed_indices.len()
|
||||
}
|
||||
|
||||
/// Returns true if no changes have been recorded.
|
||||
#[inline]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.changed_indices.is_empty()
|
||||
}
|
||||
|
||||
/// Returns the current capacity of the internal buffer.
|
||||
#[inline]
|
||||
pub fn capacity(&self) -> usize {
|
||||
self.changed_indices.capacity()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ChangeBuffer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_new() {
|
||||
let buffer = ChangeBuffer::new();
|
||||
assert!(buffer.is_empty());
|
||||
assert_eq!(buffer.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_capacity() {
|
||||
let buffer = ChangeBuffer::with_capacity(100);
|
||||
assert_eq!(buffer.capacity(), 100);
|
||||
assert!(buffer.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_push_and_drain() {
|
||||
let mut buffer = ChangeBuffer::new();
|
||||
buffer.push(U16Vec2::new(10, 10));
|
||||
buffer.push(U16Vec2::new(25, 25));
|
||||
buffer.push(U16Vec2::new(42, 42));
|
||||
|
||||
assert_eq!(buffer.len(), 3);
|
||||
assert!(buffer.has_changes());
|
||||
|
||||
let changes: Vec<_> = buffer.drain().collect();
|
||||
assert_eq!(changes, vec![U16Vec2::new(10, 10), U16Vec2::new(25, 25), U16Vec2::new(42, 42)]);
|
||||
assert!(buffer.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear() {
|
||||
let mut buffer = ChangeBuffer::new();
|
||||
buffer.push(U16Vec2::new(1, 1));
|
||||
buffer.push(U16Vec2::new(2, 2));
|
||||
buffer.push(U16Vec2::new(3, 3));
|
||||
|
||||
assert_eq!(buffer.len(), 3);
|
||||
buffer.clear();
|
||||
assert_eq!(buffer.len(), 0);
|
||||
assert!(buffer.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duplicate_indices() {
|
||||
let mut buffer = ChangeBuffer::new();
|
||||
buffer.push(U16Vec2::new(10, 10));
|
||||
buffer.push(U16Vec2::new(10, 10));
|
||||
buffer.push(U16Vec2::new(10, 10));
|
||||
|
||||
assert_eq!(buffer.len(), 1); // Automatically deduplicates
|
||||
let changes: Vec<_> = buffer.drain().collect();
|
||||
assert_eq!(changes.len(), 1); // Only one unique entry
|
||||
assert!(changes.contains(&U16Vec2::new(10, 10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capacity_retained_after_drain() {
|
||||
let mut buffer = ChangeBuffer::with_capacity(100);
|
||||
buffer.push(U16Vec2::new(1, 1));
|
||||
buffer.push(U16Vec2::new(2, 2));
|
||||
|
||||
let initial_capacity = buffer.capacity();
|
||||
let _: Vec<_> = buffer.drain().collect();
|
||||
|
||||
// Capacity should be retained after drain
|
||||
assert!(buffer.capacity() >= initial_capacity);
|
||||
assert!(buffer.is_empty());
|
||||
}
|
||||
}
|
||||
73
crates/borders-core/src/game/world/coastal.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use glam::U16Vec2;
|
||||
|
||||
use crate::game::core::utils::neighbors;
|
||||
use crate::game::terrain::TerrainData;
|
||||
|
||||
/// Resource containing precomputed coastal tile positions
|
||||
///
|
||||
/// A coastal tile is defined as a land tile (not water) that is adjacent
|
||||
/// to at least one water tile in 4-directional connectivity.
|
||||
///
|
||||
/// This is computed once during game initialization and never changes,
|
||||
/// providing O(1) lookups for systems that need to check if a tile is coastal.
|
||||
#[derive(Resource)]
|
||||
pub struct CoastalTiles {
|
||||
tiles: HashSet<U16Vec2>,
|
||||
}
|
||||
|
||||
impl CoastalTiles {
|
||||
/// Compute all coastal tile positions from terrain data
|
||||
///
|
||||
/// This scans the entire map once to find all land tiles adjacent to water.
|
||||
/// The result is cached in a HashSet for fast lookups.
|
||||
pub fn compute(terrain: &TerrainData, size: U16Vec2) -> Self {
|
||||
let mut coastal_tiles = HashSet::new();
|
||||
let width = size.x as usize;
|
||||
let height = size.y as usize;
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let tile_pos = U16Vec2::new(x as u16, y as u16);
|
||||
|
||||
// Skip water tiles
|
||||
if terrain.is_navigable(tile_pos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if any neighbor is water using the neighbors utility
|
||||
if neighbors(tile_pos, size).any(|neighbor| terrain.is_navigable(neighbor)) {
|
||||
coastal_tiles.insert(tile_pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self { tiles: coastal_tiles }
|
||||
}
|
||||
|
||||
/// Check if a tile is coastal
|
||||
#[inline]
|
||||
pub fn contains(&self, tile: U16Vec2) -> bool {
|
||||
self.tiles.contains(&tile)
|
||||
}
|
||||
|
||||
/// Get a reference to the set of all coastal tiles
|
||||
#[inline]
|
||||
pub fn tiles(&self) -> &HashSet<U16Vec2> {
|
||||
&self.tiles
|
||||
}
|
||||
|
||||
/// Get the number of coastal tiles
|
||||
#[inline]
|
||||
pub fn len(&self) -> usize {
|
||||
self.tiles.len()
|
||||
}
|
||||
|
||||
/// Check if there are no coastal tiles
|
||||
#[inline]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.tiles.is_empty()
|
||||
}
|
||||
}
|
||||
200
crates/borders-core/src/game/world/manager.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
use bevy_ecs::prelude::*;
|
||||
use glam::U16Vec2;
|
||||
|
||||
use super::changes::ChangeBuffer;
|
||||
use super::tilemap::TileMap;
|
||||
use super::{NationId, TileOwnership};
|
||||
use crate::game::utils::neighbors;
|
||||
|
||||
/// Manages territory ownership for all tiles
|
||||
#[derive(Resource)]
|
||||
pub struct TerritoryManager {
|
||||
tile_owners: TileMap<TileOwnership>,
|
||||
changes: ChangeBuffer,
|
||||
/// Cached u16 representation for efficient serialization to frontend
|
||||
u16_cache: Vec<u16>,
|
||||
cache_dirty: bool,
|
||||
}
|
||||
|
||||
impl TerritoryManager {
|
||||
/// Creates a new territory manager
|
||||
pub fn new(width: u16, height: u16) -> Self {
|
||||
let size = (width as usize) * (height as usize);
|
||||
Self { tile_owners: TileMap::with_default(width, height, TileOwnership::Unclaimed), changes: ChangeBuffer::with_capacity(size / 100), u16_cache: vec![0; size], cache_dirty: true }
|
||||
}
|
||||
|
||||
/// Resets the territory manager
|
||||
pub fn reset(&mut self, width: u16, height: u16, _conquerable_tiles: &[bool]) {
|
||||
self.tile_owners = TileMap::with_default(width, height, TileOwnership::Unclaimed);
|
||||
self.changes.clear();
|
||||
|
||||
let size = (width as usize) * (height as usize);
|
||||
self.u16_cache.resize(size, 0);
|
||||
self.cache_dirty = true;
|
||||
}
|
||||
|
||||
/// Checks if a tile is a border tile of the territory of its owner
|
||||
/// A tile is a border tile if it is adjacent to a tile that is not owned by the same player
|
||||
pub fn is_border(&self, tile: U16Vec2) -> bool {
|
||||
let owner = self.tile_owners[tile];
|
||||
|
||||
// Border if on map edge
|
||||
if tile.x == 0 || tile.x == self.tile_owners.width() - 1 || tile.y == 0 || tile.y == self.tile_owners.height() - 1 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Border if any neighbor has different owner
|
||||
for neighbor_pos in self.tile_owners.neighbors(tile) {
|
||||
if self.tile_owners[neighbor_pos] != owner {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Checks if a tile has an owner
|
||||
pub fn has_owner(&self, tile: U16Vec2) -> bool {
|
||||
self.tile_owners[tile].is_owned()
|
||||
}
|
||||
|
||||
/// Checks if a tile is owned by a specific nation
|
||||
pub fn is_owner(&self, tile: U16Vec2, owner: NationId) -> bool {
|
||||
self.tile_owners[tile].is_owned_by(owner)
|
||||
}
|
||||
|
||||
/// Gets the nation ID of the tile owner, if any
|
||||
pub fn get_nation_id(&self, tile: U16Vec2) -> Option<NationId> {
|
||||
self.tile_owners[tile].nation_id()
|
||||
}
|
||||
|
||||
/// Gets the ownership enum for a tile
|
||||
pub fn get_ownership(&self, tile: U16Vec2) -> TileOwnership {
|
||||
self.tile_owners[tile]
|
||||
}
|
||||
|
||||
/// Conquers a tile for a nation
|
||||
/// Returns the previous owner, if any
|
||||
pub fn conquer(&mut self, tile: U16Vec2, owner: NationId) -> Option<NationId> {
|
||||
let previous_owner = self.tile_owners[tile];
|
||||
let new_ownership = TileOwnership::Owned(owner);
|
||||
|
||||
if previous_owner != new_ownership {
|
||||
self.tile_owners[tile] = new_ownership;
|
||||
self.changes.push(tile);
|
||||
self.cache_dirty = true;
|
||||
}
|
||||
|
||||
previous_owner.nation_id()
|
||||
}
|
||||
|
||||
/// Clears a tile (removes ownership)
|
||||
pub fn clear(&mut self, tile: U16Vec2) -> Option<NationId> {
|
||||
let ownership = self.tile_owners[tile];
|
||||
if ownership.is_owned() {
|
||||
self.tile_owners[tile] = TileOwnership::Unclaimed;
|
||||
self.changes.push(tile);
|
||||
self.cache_dirty = true;
|
||||
ownership.nation_id()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the size of the map as U16Vec2
|
||||
#[inline]
|
||||
pub fn size(&self) -> U16Vec2 {
|
||||
self.tile_owners.size()
|
||||
}
|
||||
|
||||
/// Get width of the map
|
||||
#[inline]
|
||||
pub fn width(&self) -> u16 {
|
||||
self.tile_owners.width()
|
||||
}
|
||||
|
||||
/// Get height of the map
|
||||
#[inline]
|
||||
pub fn height(&self) -> u16 {
|
||||
self.tile_owners.height()
|
||||
}
|
||||
|
||||
/// Returns a reference to the underlying tile ownership data as a slice of enums
|
||||
#[inline]
|
||||
pub fn as_slice(&self) -> &[TileOwnership] {
|
||||
self.tile_owners.as_slice()
|
||||
}
|
||||
|
||||
/// Returns the tile ownership data as u16 values for frontend serialization
|
||||
/// This is cached and only recomputed when ownership changes
|
||||
pub fn as_u16_slice(&mut self) -> &[u16] {
|
||||
if self.cache_dirty {
|
||||
let tile_count = self.tile_owners.len();
|
||||
let _guard = tracing::trace_span!("rebuild_u16_cache", tile_count).entered();
|
||||
|
||||
for (i, ownership) in self.tile_owners.as_slice().iter().enumerate() {
|
||||
self.u16_cache[i] = (*ownership).into();
|
||||
}
|
||||
self.cache_dirty = false;
|
||||
}
|
||||
&self.u16_cache
|
||||
}
|
||||
|
||||
/// Returns the number of tiles in the map
|
||||
#[inline]
|
||||
pub fn len(&self) -> usize {
|
||||
self.tile_owners.len()
|
||||
}
|
||||
|
||||
/// Returns true if the map has no tiles
|
||||
#[inline]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.tile_owners.len() == 0
|
||||
}
|
||||
|
||||
/// Returns an iterator over changed tile positions without consuming them
|
||||
/// Use this to read changes without clearing the buffer
|
||||
#[inline]
|
||||
pub fn iter_changes(&self) -> impl Iterator<Item = U16Vec2> + '_ {
|
||||
self.changes.iter()
|
||||
}
|
||||
|
||||
/// Drains all changed tile positions, returning an iterator and clearing the change buffer
|
||||
#[inline]
|
||||
pub fn drain_changes(&mut self) -> impl Iterator<Item = U16Vec2> + '_ {
|
||||
self.changes.drain()
|
||||
}
|
||||
|
||||
/// Returns true if any territory changes have been recorded since last drain
|
||||
#[inline]
|
||||
pub fn has_changes(&self) -> bool {
|
||||
self.changes.has_changes()
|
||||
}
|
||||
|
||||
/// Clears all tracked changes without returning them
|
||||
#[inline]
|
||||
pub fn clear_changes(&mut self) {
|
||||
self.changes.clear()
|
||||
}
|
||||
|
||||
/// Calls a closure for each neighbor using tile indices (legacy compatibility)
|
||||
#[inline]
|
||||
pub fn on_neighbor_indices<F>(&self, index: u32, closure: F)
|
||||
where
|
||||
F: FnMut(u32),
|
||||
{
|
||||
self.tile_owners.on_neighbor_indices(index, closure)
|
||||
}
|
||||
|
||||
/// Checks if any neighbor has a different owner than the specified owner
|
||||
pub fn any_neighbor_has_different_owner(&self, tile: U16Vec2, owner: NationId) -> bool {
|
||||
let owner_enum = TileOwnership::Owned(owner);
|
||||
neighbors(tile, self.size()).any(|neighbor| self.tile_owners[neighbor] != owner_enum)
|
||||
}
|
||||
|
||||
/// Converts position to flat u32 index for JavaScript/IPC boundary
|
||||
#[inline]
|
||||
pub fn pos_to_index<P: Into<U16Vec2>>(&self, pos: P) -> u32 {
|
||||
self.tile_owners.pos_to_index(pos)
|
||||
}
|
||||
}
|
||||
17
crates/borders-core/src/game/world/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
//! World and territory management module
|
||||
//!
|
||||
//! This module contains all spatial data structures and territory management.
|
||||
|
||||
pub mod changes;
|
||||
pub mod coastal;
|
||||
pub mod manager;
|
||||
pub mod nation_id;
|
||||
pub mod ownership;
|
||||
pub mod tilemap;
|
||||
|
||||
pub use changes::*;
|
||||
pub use coastal::*;
|
||||
pub use manager::*;
|
||||
pub use nation_id::*;
|
||||
pub use ownership::*;
|
||||
pub use tilemap::*;
|
||||
152
crates/borders-core/src/game/world/nation_id.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
use bevy_ecs::prelude::Component;
|
||||
use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
/// Unique identifier for a nation/player in the game.
|
||||
///
|
||||
/// This is a validated newtype wrapper around u16 that prevents invalid nation IDs
|
||||
/// from being constructed. The maximum valid nation ID is 65534, as 65535 is reserved
|
||||
/// for encoding unclaimed tiles in the ownership serialization format.
|
||||
#[derive(Component, Debug, Copy, Clone, Eq, PartialEq, Hash, Ord, PartialOrd, Archive, RkyvSerialize, RkyvDeserialize)]
|
||||
#[rkyv(derive(Debug, Hash, PartialEq, Eq))]
|
||||
pub struct NationId(u16);
|
||||
|
||||
impl NationId {
|
||||
/// Maximum valid nation ID (65534). Value 65535 is reserved for unclaimed tiles.
|
||||
pub const MAX: u16 = u16::MAX - 1;
|
||||
|
||||
/// Constant for nation ID 0 (commonly used for default/first player)
|
||||
pub const ZERO: Self = Self(0);
|
||||
|
||||
/// Creates a new NationId if the value is valid (<= MAX).
|
||||
///
|
||||
/// Returns None if id > MAX (i.e., id == 65535).
|
||||
#[inline]
|
||||
pub fn new(id: u16) -> Option<Self> {
|
||||
(id <= Self::MAX).then_some(Self(id))
|
||||
}
|
||||
|
||||
/// Creates a NationId without validation.
|
||||
///
|
||||
/// # Safety
|
||||
/// Caller must ensure id <= MAX. This is primarily for const contexts.
|
||||
#[inline]
|
||||
pub const fn new_unchecked(id: u16) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
|
||||
/// Extracts the inner u16 value.
|
||||
#[inline]
|
||||
pub fn get(self) -> u16 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Converts to little-endian bytes.
|
||||
#[inline]
|
||||
pub fn to_le_bytes(self) -> [u8; 2] {
|
||||
self.0.to_le_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<u16> for NationId {
|
||||
type Error = InvalidNationId;
|
||||
|
||||
fn try_from(value: u16) -> Result<Self, Self::Error> {
|
||||
Self::new(value).ok_or(InvalidNationId(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<NationId> for u16 {
|
||||
fn from(id: NationId) -> Self {
|
||||
id.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for NationId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Error type for invalid nation ID values
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct InvalidNationId(pub u16);
|
||||
|
||||
impl std::fmt::Display for InvalidNationId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Invalid nation ID: {} (must be <= {})", self.0, NationId::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for InvalidNationId {}
|
||||
|
||||
impl Serialize for NationId {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
Serialize::serialize(&self.0, serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for NationId {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = u16::deserialize(deserializer)?;
|
||||
NationId::new(value).ok_or_else(|| serde::de::Error::custom(format!("Invalid nation ID: {} (must be <= {})", value, NationId::MAX)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_nation_ids() {
|
||||
assert!(NationId::new(0).is_some());
|
||||
assert!(NationId::new(100).is_some());
|
||||
assert!(NationId::new(65534).is_some());
|
||||
assert_eq!(NationId::new(65534).unwrap().get(), 65534);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_nation_id() {
|
||||
assert!(NationId::new(65535).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_try_from() {
|
||||
assert!(NationId::try_from(0).is_ok());
|
||||
assert!(NationId::try_from(65534).is_ok());
|
||||
assert!(NationId::try_from(65535).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_into_u16() {
|
||||
let id = NationId::new(42).unwrap();
|
||||
let raw: u16 = id.into();
|
||||
assert_eq!(raw, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip() {
|
||||
let id = NationId::new(123).unwrap();
|
||||
let json = serde_json::to_string(&id).unwrap();
|
||||
let deserialized: NationId = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(id, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_invalid() {
|
||||
let json = "65535";
|
||||
let result: Result<NationId, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_constant() {
|
||||
assert_eq!(NationId::MAX, 65534);
|
||||
}
|
||||
}
|
||||
121
crates/borders-core/src/game/world/ownership.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
//! Tile ownership representation
|
||||
|
||||
use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::NationId;
|
||||
|
||||
/// Represents the ownership state of a single tile.
|
||||
///
|
||||
/// Terrain type (water, land, mountain, etc.) is stored separately in TerrainData.
|
||||
/// This enum only tracks whether a tile is owned by a nation or unclaimed.
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize, Default, Archive, RkyvSerialize, RkyvDeserialize)]
|
||||
#[rkyv(derive(Debug))]
|
||||
pub enum TileOwnership {
|
||||
/// Owned by a specific nation
|
||||
Owned(NationId),
|
||||
/// Unclaimed but potentially conquerable land
|
||||
#[default]
|
||||
Unclaimed,
|
||||
}
|
||||
|
||||
impl TileOwnership {
|
||||
/// Check if this tile is owned by any nation
|
||||
#[inline]
|
||||
pub fn is_owned(self) -> bool {
|
||||
matches!(self, TileOwnership::Owned(_))
|
||||
}
|
||||
|
||||
/// Check if this tile is unclaimed land
|
||||
#[inline]
|
||||
pub fn is_unclaimed(self) -> bool {
|
||||
matches!(self, TileOwnership::Unclaimed)
|
||||
}
|
||||
|
||||
/// Get the nation ID if this tile is owned, otherwise None
|
||||
#[inline]
|
||||
pub fn nation_id(self) -> Option<NationId> {
|
||||
match self {
|
||||
TileOwnership::Owned(id) => Some(id),
|
||||
TileOwnership::Unclaimed => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this tile is owned by a specific nation
|
||||
#[inline]
|
||||
pub fn is_owned_by(self, nation_id: NationId) -> bool {
|
||||
matches!(self, TileOwnership::Owned(id) if id == nation_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u16> for TileOwnership {
|
||||
fn from(value: u16) -> Self {
|
||||
if value == 65535 { TileOwnership::Unclaimed } else { NationId::new(value).map(TileOwnership::Owned).unwrap_or(TileOwnership::Unclaimed) }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TileOwnership> for u16 {
|
||||
fn from(ownership: TileOwnership) -> Self {
|
||||
match ownership {
|
||||
TileOwnership::Owned(id) => id.get(),
|
||||
TileOwnership::Unclaimed => 65535,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_encoding_nation_ids() {
|
||||
for raw_id in [0, 1, 100, 1000, 65534] {
|
||||
let nation_id = NationId::new(raw_id).unwrap();
|
||||
let ownership = TileOwnership::Owned(nation_id);
|
||||
let encoded: u16 = ownership.into();
|
||||
assert_eq!(encoded, raw_id);
|
||||
assert_eq!(TileOwnership::from(encoded), ownership);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encoding_unclaimed() {
|
||||
let ownership = TileOwnership::Unclaimed;
|
||||
let encoded: u16 = ownership.into();
|
||||
assert_eq!(encoded, 65535);
|
||||
assert_eq!(TileOwnership::from(encoded), ownership);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_owned() {
|
||||
assert!(TileOwnership::Owned(NationId::new(0).unwrap()).is_owned());
|
||||
assert!(TileOwnership::Owned(NationId::new(100).unwrap()).is_owned());
|
||||
assert!(!TileOwnership::Unclaimed.is_owned());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_owned_by() {
|
||||
let id5 = NationId::new(5).unwrap();
|
||||
let id6 = NationId::new(6).unwrap();
|
||||
assert!(TileOwnership::Owned(id5).is_owned_by(id5));
|
||||
assert!(!TileOwnership::Owned(id5).is_owned_by(id6));
|
||||
assert!(!TileOwnership::Unclaimed.is_owned_by(id5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nation_id() {
|
||||
let id42 = NationId::new(42).unwrap();
|
||||
assert_eq!(TileOwnership::Owned(id42).nation_id(), Some(id42));
|
||||
assert_eq!(TileOwnership::Unclaimed.nation_id(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nation_zero_is_valid() {
|
||||
let id0 = NationId::new(0).unwrap();
|
||||
let ownership = TileOwnership::Owned(id0);
|
||||
assert!(ownership.is_owned());
|
||||
assert!(ownership.is_owned_by(id0));
|
||||
assert_eq!(ownership.nation_id(), Some(id0));
|
||||
assert_ne!(ownership, TileOwnership::Unclaimed);
|
||||
}
|
||||
}
|
||||
446
crates/borders-core/src/game/world/tilemap.rs
Normal file
@@ -0,0 +1,446 @@
|
||||
use glam::{U16Vec2, UVec2};
|
||||
use std::ops::{Index, IndexMut};
|
||||
|
||||
/// A 2D grid-based map structure optimized for tile-based games.
|
||||
///
|
||||
/// Provides efficient access to tiles using 2D coordinates (U16Vec2) while maintaining
|
||||
/// cache-friendly contiguous memory layout. Supports generic tile types that implement Copy.
|
||||
///
|
||||
/// Uses `u16` for dimensions, supporting maps up to 65,535x65,535 tiles.
|
||||
///
|
||||
/// # Type Parameters
|
||||
/// * `T` - The tile value type. Must implement `Copy` for efficient access.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use glam::U16Vec2;
|
||||
/// use borders_core::game::TileMap;
|
||||
///
|
||||
/// let mut map = TileMap::<u8>::new(10, 10);
|
||||
/// map[U16Vec2::new(5, 5)] = 42;
|
||||
/// assert_eq!(map[U16Vec2::new(5, 5)], 42);
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TileMap<T: Copy> {
|
||||
tiles: Box<[T]>,
|
||||
size: U16Vec2,
|
||||
}
|
||||
|
||||
impl<T: Copy> TileMap<T> {
|
||||
/// Creates a new TileMap with the specified dimensions and default value.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `width` - The width of the map in tiles
|
||||
/// * `height` - The height of the map in tiles
|
||||
/// * `default` - The default value to initialize all tiles with
|
||||
pub fn with_default(width: u16, height: u16, default: T) -> Self {
|
||||
let capacity = (width as usize) * (height as usize);
|
||||
let tiles = vec![default; capacity].into_boxed_slice();
|
||||
Self { tiles, size: U16Vec2::new(width, height) }
|
||||
}
|
||||
|
||||
/// Creates a TileMap from an existing vector of tile data.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `width` - The width of the map in tiles
|
||||
/// * `height` - The height of the map in tiles
|
||||
/// * `data` - Vector containing tile data in row-major order
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `data.len() != width * height`
|
||||
pub fn from_vec(width: u16, height: u16, data: Vec<T>) -> Self {
|
||||
assert_eq!(data.len(), (width as usize) * (height as usize), "Data length must match width * height");
|
||||
Self { tiles: data.into_boxed_slice(), size: U16Vec2::new(width, height) }
|
||||
}
|
||||
|
||||
/// Converts the position to a flat array index.
|
||||
///
|
||||
/// Accepts both U16Vec2 and UVec2 for backward compatibility.
|
||||
///
|
||||
/// # Safety
|
||||
/// Debug builds will assert that the position is in bounds.
|
||||
/// Release builds skip the check for performance.
|
||||
#[inline]
|
||||
pub fn pos_to_index<P: Into<U16Vec2>>(&self, pos: P) -> u32 {
|
||||
let pos = pos.into();
|
||||
debug_assert!(pos.x < self.size.x && pos.y < self.size.y);
|
||||
(pos.y as u32) * (self.size.x as u32) + (pos.x as u32)
|
||||
}
|
||||
|
||||
/// Converts a flat array index to a 2D position.
|
||||
#[inline]
|
||||
pub fn index_to_pos(&self, index: u32) -> U16Vec2 {
|
||||
debug_assert!(index < self.tiles.len() as u32);
|
||||
let width = self.size.x as u32;
|
||||
U16Vec2::new((index % width) as u16, (index / width) as u16)
|
||||
}
|
||||
|
||||
/// Checks if a position is within the map bounds.
|
||||
///
|
||||
/// Accepts both U16Vec2 and UVec2 for backward compatibility.
|
||||
#[inline]
|
||||
pub fn in_bounds<P: Into<U16Vec2>>(&self, pos: P) -> bool {
|
||||
let pos = pos.into();
|
||||
pos.x < self.size.x && pos.y < self.size.y
|
||||
}
|
||||
|
||||
/// Gets the tile value at the specified position.
|
||||
///
|
||||
/// Returns `None` if the position is out of bounds.
|
||||
pub fn get<P: Into<U16Vec2>>(&self, pos: P) -> Option<T> {
|
||||
let pos = pos.into();
|
||||
if self.in_bounds(pos) { Some(self.tiles[self.pos_to_index(pos) as usize]) } else { None }
|
||||
}
|
||||
|
||||
/// Sets the tile value at the specified position.
|
||||
///
|
||||
/// Returns `true` if the position was in bounds and the value was set,
|
||||
/// `false` otherwise.
|
||||
pub fn set<P: Into<U16Vec2>>(&mut self, pos: P, tile: T) -> bool {
|
||||
let pos = pos.into();
|
||||
if self.in_bounds(pos) {
|
||||
let idx = self.pos_to_index(pos) as usize;
|
||||
self.tiles[idx] = tile;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the size of the map as U16Vec2.
|
||||
#[inline]
|
||||
pub fn size(&self) -> U16Vec2 {
|
||||
self.size
|
||||
}
|
||||
|
||||
/// Returns the width of the map.
|
||||
#[inline]
|
||||
pub fn width(&self) -> u16 {
|
||||
self.size.x
|
||||
}
|
||||
|
||||
/// Returns the height of the map.
|
||||
#[inline]
|
||||
pub fn height(&self) -> u16 {
|
||||
self.size.y
|
||||
}
|
||||
|
||||
/// Returns the total number of tiles in the map.
|
||||
#[inline]
|
||||
pub fn len(&self) -> usize {
|
||||
self.tiles.len()
|
||||
}
|
||||
|
||||
/// Returns `true` if the map contains no tiles.
|
||||
#[inline]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.tiles.is_empty()
|
||||
}
|
||||
|
||||
/// Returns an iterator over all valid cardinal neighbors of a position.
|
||||
///
|
||||
/// Yields positions for up, down, left, and right neighbors that are within bounds.
|
||||
pub fn neighbors<P: Into<U16Vec2>>(&self, pos: P) -> impl Iterator<Item = U16Vec2> {
|
||||
crate::game::utils::neighbors(pos.into(), self.size)
|
||||
}
|
||||
|
||||
/// Calls a closure for each neighbor using tile indices instead of positions.
|
||||
///
|
||||
/// This is useful when working with systems that still use raw indices.
|
||||
pub fn on_neighbor_indices<F>(&self, index: u32, mut closure: F)
|
||||
where
|
||||
F: FnMut(u32),
|
||||
{
|
||||
let width = self.size.x as u32;
|
||||
let height = self.size.y as u32;
|
||||
let x = index % width;
|
||||
let y = index / width;
|
||||
|
||||
if x > 0 {
|
||||
closure(index - 1);
|
||||
}
|
||||
if x < width - 1 {
|
||||
closure(index + 1);
|
||||
}
|
||||
if y > 0 {
|
||||
closure(index - width);
|
||||
}
|
||||
if y < height - 1 {
|
||||
closure(index + width);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an iterator over all positions and their tile values.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (U16Vec2, T)> + '_ {
|
||||
(0..self.size.y).flat_map(move |y| {
|
||||
(0..self.size.x).map(move |x| {
|
||||
let pos = U16Vec2::new(x, y);
|
||||
(pos, self[pos])
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns an iterator over just the tile values.
|
||||
pub fn iter_values(&self) -> impl Iterator<Item = T> + '_ {
|
||||
self.tiles.iter().copied()
|
||||
}
|
||||
|
||||
/// Returns an iterator over all positions in the map.
|
||||
pub fn positions(&self) -> impl Iterator<Item = U16Vec2> + '_ {
|
||||
(0..self.size.y).flat_map(move |y| (0..self.size.x).map(move |x| U16Vec2::new(x, y)))
|
||||
}
|
||||
|
||||
/// Returns an iterator over tile indices, positions, and values.
|
||||
pub fn enumerate(&self) -> impl Iterator<Item = (usize, U16Vec2, T)> + '_ {
|
||||
self.tiles.iter().enumerate().map(move |(idx, &value)| {
|
||||
let pos = self.index_to_pos(idx as u32);
|
||||
(idx, pos, value)
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a reference to the underlying tile data as a slice.
|
||||
pub fn as_slice(&self) -> &[T] {
|
||||
&self.tiles
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the underlying tile data as a slice.
|
||||
pub fn as_mut_slice(&mut self) -> &mut [T] {
|
||||
&mut self.tiles
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Copy + Default> TileMap<T> {
|
||||
/// Creates a new TileMap with the specified dimensions, using T::default() for initialization.
|
||||
pub fn new(width: u16, height: u16) -> Self {
|
||||
Self::with_default(width, height, T::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Copy> Index<U16Vec2> for TileMap<T> {
|
||||
type Output = T;
|
||||
|
||||
#[inline]
|
||||
fn index(&self, pos: U16Vec2) -> &Self::Output {
|
||||
&self.tiles[self.pos_to_index(pos) as usize]
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Copy> IndexMut<U16Vec2> for TileMap<T> {
|
||||
#[inline]
|
||||
fn index_mut(&mut self, pos: U16Vec2) -> &mut Self::Output {
|
||||
let idx = self.pos_to_index(pos) as usize;
|
||||
&mut self.tiles[idx]
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compatibility: allow indexing with UVec2
|
||||
impl<T: Copy> Index<UVec2> for TileMap<T> {
|
||||
type Output = T;
|
||||
|
||||
#[inline]
|
||||
fn index(&self, pos: UVec2) -> &Self::Output {
|
||||
let pos16 = U16Vec2::new(pos.x as u16, pos.y as u16);
|
||||
&self.tiles[self.pos_to_index(pos16) as usize]
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Copy> IndexMut<UVec2> for TileMap<T> {
|
||||
#[inline]
|
||||
fn index_mut(&mut self, pos: UVec2) -> &mut Self::Output {
|
||||
let pos16 = U16Vec2::new(pos.x as u16, pos.y as u16);
|
||||
let idx = self.pos_to_index(pos16) as usize;
|
||||
&mut self.tiles[idx]
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Copy> Index<usize> for TileMap<T> {
|
||||
type Output = T;
|
||||
|
||||
#[inline]
|
||||
fn index(&self, index: usize) -> &Self::Output {
|
||||
&self.tiles[index]
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Copy> IndexMut<usize> for TileMap<T> {
|
||||
#[inline]
|
||||
fn index_mut(&mut self, index: usize) -> &mut Self::Output {
|
||||
&mut self.tiles[index]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_new_with_default() {
|
||||
let map = TileMap::<u8>::with_default(10, 10, 42);
|
||||
assert_eq!(map.width(), 10);
|
||||
assert_eq!(map.height(), 10);
|
||||
assert_eq!(map[U16Vec2::new(0, 0)], 42);
|
||||
assert_eq!(map[U16Vec2::new(9, 9)], 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_vec() {
|
||||
let data = vec![1u8, 2, 3, 4];
|
||||
let map = TileMap::from_vec(2, 2, data);
|
||||
assert_eq!(map[U16Vec2::new(0, 0)], 1);
|
||||
assert_eq!(map[U16Vec2::new(1, 0)], 2);
|
||||
assert_eq!(map[U16Vec2::new(0, 1)], 3);
|
||||
assert_eq!(map[U16Vec2::new(1, 1)], 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pos_to_index() {
|
||||
let map = TileMap::<u8>::with_default(10, 10, 0);
|
||||
assert_eq!(map.pos_to_index(U16Vec2::new(0, 0)), 0);
|
||||
assert_eq!(map.pos_to_index(U16Vec2::new(5, 0)), 5);
|
||||
assert_eq!(map.pos_to_index(U16Vec2::new(0, 1)), 10);
|
||||
assert_eq!(map.pos_to_index(U16Vec2::new(3, 2)), 23);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_index_to_pos() {
|
||||
let map = TileMap::<u8>::with_default(10, 10, 0);
|
||||
assert_eq!(map.index_to_pos(0), U16Vec2::new(0, 0));
|
||||
assert_eq!(map.index_to_pos(5), U16Vec2::new(5, 0));
|
||||
assert_eq!(map.index_to_pos(10), U16Vec2::new(0, 1));
|
||||
assert_eq!(map.index_to_pos(23), U16Vec2::new(3, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_in_bounds() {
|
||||
let map = TileMap::<u8>::with_default(10, 10, 0);
|
||||
assert!(map.in_bounds(U16Vec2::new(0, 0)));
|
||||
assert!(map.in_bounds(U16Vec2::new(9, 9)));
|
||||
assert!(!map.in_bounds(U16Vec2::new(10, 0)));
|
||||
assert!(!map.in_bounds(U16Vec2::new(0, 10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_set() {
|
||||
let mut map = TileMap::<u8>::with_default(10, 10, 0);
|
||||
assert_eq!(map.get(U16Vec2::new(5, 5)), Some(0));
|
||||
|
||||
assert!(map.set(U16Vec2::new(5, 5), 42));
|
||||
assert_eq!(map.get(U16Vec2::new(5, 5)), Some(42));
|
||||
|
||||
assert!(!map.set(U16Vec2::new(10, 10), 99));
|
||||
assert_eq!(map.get(U16Vec2::new(10, 10)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_index_operators() {
|
||||
let mut map = TileMap::<u8>::with_default(10, 10, 0);
|
||||
map[U16Vec2::new(5, 5)] = 42;
|
||||
assert_eq!(map[U16Vec2::new(5, 5)], 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_index_by_usize() {
|
||||
let mut map = TileMap::<u8>::with_default(10, 10, 0);
|
||||
map[23] = 42;
|
||||
assert_eq!(map[23], 42);
|
||||
assert_eq!(map[U16Vec2::new(3, 2)], 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backward_compat_uvec2() {
|
||||
// Test backward compatibility with UVec2
|
||||
let mut map = TileMap::<u8>::with_default(10, 10, 0);
|
||||
map[UVec2::new(5, 5)] = 42;
|
||||
assert_eq!(map[UVec2::new(5, 5)], 42);
|
||||
assert_eq!(map.get(U16Vec2::new(5, 5)), Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_neighbors_center() {
|
||||
let map = TileMap::<u8>::with_default(10, 10, 0);
|
||||
let neighbors: Vec<_> = map.neighbors(U16Vec2::new(5, 5)).collect();
|
||||
assert_eq!(neighbors.len(), 4);
|
||||
assert!(neighbors.contains(&U16Vec2::new(5, 6)));
|
||||
assert!(neighbors.contains(&U16Vec2::new(6, 5)));
|
||||
assert!(neighbors.contains(&U16Vec2::new(5, 4)));
|
||||
assert!(neighbors.contains(&U16Vec2::new(4, 5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_neighbors_corner() {
|
||||
let map = TileMap::<u8>::with_default(10, 10, 0);
|
||||
let neighbors: Vec<_> = map.neighbors(U16Vec2::new(0, 0)).collect();
|
||||
assert_eq!(neighbors.len(), 2);
|
||||
assert!(neighbors.contains(&U16Vec2::new(1, 0)));
|
||||
assert!(neighbors.contains(&U16Vec2::new(0, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_neighbors_edge() {
|
||||
let map = TileMap::<u8>::with_default(10, 10, 0);
|
||||
let neighbors: Vec<_> = map.neighbors(U16Vec2::new(0, 5)).collect();
|
||||
assert_eq!(neighbors.len(), 3);
|
||||
assert!(neighbors.contains(&U16Vec2::new(0, 6)));
|
||||
assert!(neighbors.contains(&U16Vec2::new(1, 5)));
|
||||
assert!(neighbors.contains(&U16Vec2::new(0, 4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_on_neighbor_indices() {
|
||||
let map = TileMap::<u8>::with_default(10, 10, 0);
|
||||
let center_idx = map.pos_to_index(U16Vec2::new(5, 5));
|
||||
let mut count = 0;
|
||||
map.on_neighbor_indices(center_idx, |_| count += 1);
|
||||
assert_eq!(count, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_iter() {
|
||||
let map = TileMap::<u8>::with_default(2, 2, 0);
|
||||
let positions: Vec<_> = map.iter().map(|(pos, _)| pos).collect();
|
||||
assert_eq!(positions.len(), 4);
|
||||
assert!(positions.contains(&U16Vec2::new(0, 0)));
|
||||
assert!(positions.contains(&U16Vec2::new(1, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_iter_values() {
|
||||
let map = TileMap::<u8>::with_default(2, 2, 42);
|
||||
let values: Vec<_> = map.iter_values().collect();
|
||||
assert_eq!(values, vec![42, 42, 42, 42]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_positions() {
|
||||
let map = TileMap::<u8>::with_default(2, 2, 0);
|
||||
let positions: Vec<_> = map.positions().collect();
|
||||
assert_eq!(positions.len(), 4);
|
||||
assert_eq!(positions[0], U16Vec2::new(0, 0));
|
||||
assert_eq!(positions[3], U16Vec2::new(1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enumerate() {
|
||||
let mut map = TileMap::<u8>::with_default(2, 2, 0);
|
||||
map[U16Vec2::new(1, 1)] = 42;
|
||||
let entries: Vec<_> = map.enumerate().collect();
|
||||
assert_eq!(entries.len(), 4);
|
||||
assert_eq!(entries[3], (3, U16Vec2::new(1, 1), 42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generic_u16() {
|
||||
let mut map = TileMap::<u16>::with_default(5, 5, 0);
|
||||
assert_eq!(map[UVec2::new(0, 0)], 0);
|
||||
map[UVec2::new(2, 2)] = 65535;
|
||||
assert_eq!(map[UVec2::new(2, 2)], 65535);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generic_f32() {
|
||||
let mut map = TileMap::<f32>::with_default(5, 5, 1.5);
|
||||
assert_eq!(map[UVec2::new(0, 0)], 1.5);
|
||||
map[UVec2::new(2, 2)] = 2.7;
|
||||
assert_eq!(map[UVec2::new(2, 2)], 2.7);
|
||||
}
|
||||
}
|
||||
14
crates/borders-core/src/lib.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
pub mod app;
|
||||
pub mod build_info;
|
||||
pub mod game;
|
||||
pub mod networking;
|
||||
pub mod platform;
|
||||
pub mod plugin;
|
||||
pub mod telemetry;
|
||||
pub mod time;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod dns;
|
||||
|
||||
#[cfg(feature = "ui")]
|
||||
pub mod ui;
|
||||
270
crates/borders-core/src/networking/client/connection.rs
Normal file
@@ -0,0 +1,270 @@
|
||||
//! Unified connection abstraction for local and remote networking
|
||||
//!
|
||||
//! This module provides a generic `Connection` interface that abstracts away
|
||||
//! the transport mechanism (local channels vs network). Game systems interact
|
||||
//! with a single `Connection` resource regardless of whether the game is
|
||||
//! single-player or multiplayer.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use flume::{Receiver, Sender};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::game::NationId;
|
||||
use crate::networking::{Intent, Turn, protocol::NetMessage};
|
||||
|
||||
/// Intent with tracking ID for confirmation
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrackedIntent {
|
||||
pub id: u64,
|
||||
pub intent: Intent,
|
||||
}
|
||||
|
||||
/// Resource for receiving tracked intents (local mode)
|
||||
#[derive(Resource)]
|
||||
pub struct TrackedIntentReceiver {
|
||||
pub rx: Receiver<TrackedIntent>,
|
||||
}
|
||||
|
||||
/// Backend trait for connection implementations
|
||||
///
|
||||
/// This trait abstracts the transport mechanism, allowing both local
|
||||
/// and remote connections to be used interchangeably.
|
||||
pub trait ConnectionBackend: Send + Sync {
|
||||
/// Send an intent with tracking ID
|
||||
fn send_intent(&self, id: u64, intent: Intent);
|
||||
|
||||
/// Get the player ID for this connection
|
||||
fn player_id(&self) -> Option<NationId>;
|
||||
|
||||
/// Try to receive a turn (non-blocking)
|
||||
/// Returns None if no turn is available
|
||||
fn try_recv_turn(&self) -> Option<Turn>;
|
||||
}
|
||||
|
||||
/// Local backend implementation (single-player)
|
||||
pub struct LocalBackend {
|
||||
intent_tx: Sender<TrackedIntent>,
|
||||
turn_rx: Receiver<Turn>,
|
||||
player_id: NationId,
|
||||
}
|
||||
|
||||
impl LocalBackend {
|
||||
pub fn new(intent_tx: Sender<TrackedIntent>, turn_rx: Receiver<Turn>, player_id: NationId) -> Self {
|
||||
Self { intent_tx, turn_rx, player_id }
|
||||
}
|
||||
}
|
||||
|
||||
impl ConnectionBackend for LocalBackend {
|
||||
fn send_intent(&self, id: u64, intent: Intent) {
|
||||
let tracked = TrackedIntent { id, intent };
|
||||
if let Err(e) = self.intent_tx.try_send(tracked) {
|
||||
warn!("Failed to send tracked intent: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
fn player_id(&self) -> Option<NationId> {
|
||||
Some(self.player_id)
|
||||
}
|
||||
|
||||
fn try_recv_turn(&self) -> Option<Turn> {
|
||||
self.turn_rx.try_recv().ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Remote backend implementation (multiplayer)
|
||||
pub struct RemoteBackend {
|
||||
intent_tx: Sender<NetMessage>,
|
||||
net_message_rx: Receiver<NetMessage>,
|
||||
player_id: std::sync::Arc<std::sync::RwLock<Option<NationId>>>,
|
||||
}
|
||||
|
||||
impl RemoteBackend {
|
||||
pub fn new(intent_tx: Sender<NetMessage>, net_message_rx: Receiver<NetMessage>) -> Self {
|
||||
Self { intent_tx, net_message_rx, player_id: std::sync::Arc::new(std::sync::RwLock::new(None)) }
|
||||
}
|
||||
|
||||
/// Try to receive a NetMessage (for processing in receive system)
|
||||
pub fn try_recv_message(&self) -> Option<NetMessage> {
|
||||
self.net_message_rx.try_recv().ok()
|
||||
}
|
||||
|
||||
/// Set player ID when ServerConfig is received
|
||||
pub fn set_player_id(&self, player_id: NationId) {
|
||||
if let Ok(mut guard) = self.player_id.write() {
|
||||
*guard = Some(player_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConnectionBackend for RemoteBackend {
|
||||
fn send_intent(&self, id: u64, intent: Intent) {
|
||||
let msg = NetMessage::Intent { id, intent };
|
||||
if let Err(e) = self.intent_tx.try_send(msg) {
|
||||
warn!("Failed to send net intent: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
fn player_id(&self) -> Option<NationId> {
|
||||
self.player_id.read().ok().and_then(|guard| *guard)
|
||||
}
|
||||
|
||||
fn try_recv_turn(&self) -> Option<Turn> {
|
||||
// For remote, turn reception is handled by receive_messages_system
|
||||
// which processes NetMessage protocol
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Pending intent tracking info
|
||||
pub struct PendingIntent {
|
||||
pub intent: Intent,
|
||||
pub sent_turn: u64,
|
||||
}
|
||||
|
||||
/// Intent tracker for drop detection
|
||||
pub struct IntentTracker {
|
||||
next_id: u64,
|
||||
pending: HashMap<u64, PendingIntent>,
|
||||
pending_ordered: VecDeque<u64>,
|
||||
turn_buffer_size: usize,
|
||||
}
|
||||
|
||||
impl IntentTracker {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
next_id: 1,
|
||||
pending: HashMap::new(),
|
||||
pending_ordered: VecDeque::new(),
|
||||
turn_buffer_size: 5, // 500ms buffer at 100ms/turn
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_id(&mut self) -> u64 {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
id
|
||||
}
|
||||
|
||||
pub fn track_sent(&mut self, intent_id: u64, intent: Intent, current_turn: u64) {
|
||||
self.pending.insert(intent_id, PendingIntent { intent, sent_turn: current_turn });
|
||||
self.pending_ordered.push_back(intent_id);
|
||||
}
|
||||
|
||||
pub fn confirm_intent(&mut self, intent_id: u64) -> bool {
|
||||
self.pending.remove(&intent_id).is_some()
|
||||
}
|
||||
|
||||
pub fn expire_old(&mut self, current_turn: u64) -> Vec<PendingIntent> {
|
||||
let mut expired = Vec::new();
|
||||
|
||||
while let Some(&id) = self.pending_ordered.front() {
|
||||
if let Some(pending) = self.pending.get(&id) {
|
||||
// Check if still within buffer window
|
||||
if current_turn.saturating_sub(pending.sent_turn) <= self.turn_buffer_size as u64 {
|
||||
break;
|
||||
}
|
||||
// Expired - remove and warn
|
||||
expired.push(self.pending.remove(&id).unwrap());
|
||||
}
|
||||
// Either expired or already confirmed - remove from queue
|
||||
self.pending_ordered.pop_front();
|
||||
}
|
||||
|
||||
expired
|
||||
}
|
||||
|
||||
pub fn turn_buffer_size(&self) -> usize {
|
||||
self.turn_buffer_size
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for IntentTracker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified connection resource
|
||||
///
|
||||
/// This resource abstracts away local vs remote transport mechanisms.
|
||||
/// Game systems interact with this single resource regardless of network mode.
|
||||
#[derive(Resource)]
|
||||
pub struct Connection {
|
||||
backend: Box<dyn ConnectionBackend>,
|
||||
tracker: IntentTracker,
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
pub fn new(backend: Box<dyn ConnectionBackend>) -> Self {
|
||||
Self { backend, tracker: IntentTracker::new() }
|
||||
}
|
||||
|
||||
pub fn new_local(backend: LocalBackend) -> Self {
|
||||
Self::new(Box::new(backend))
|
||||
}
|
||||
|
||||
pub fn new_remote(backend: RemoteBackend) -> Self {
|
||||
Self::new(Box::new(backend))
|
||||
}
|
||||
|
||||
/// Send an intent with automatic tracking
|
||||
pub fn send_intent(&mut self, intent: Intent, current_turn: u64) {
|
||||
let intent_id = self.tracker.next_id();
|
||||
self.tracker.track_sent(intent_id, intent.clone(), current_turn);
|
||||
self.backend.send_intent(intent_id, intent);
|
||||
}
|
||||
|
||||
/// Confirm receipt of an intent by ID
|
||||
pub fn confirm_intent(&mut self, intent_id: u64) -> bool {
|
||||
let confirmed = self.tracker.confirm_intent(intent_id);
|
||||
if confirmed {
|
||||
debug!("Confirmed intent {}", intent_id);
|
||||
}
|
||||
confirmed
|
||||
}
|
||||
|
||||
/// Check for expired intents that weren't received
|
||||
pub fn check_expired(&mut self, current_turn: u64) -> Vec<PendingIntent> {
|
||||
self.tracker.expire_old(current_turn)
|
||||
}
|
||||
|
||||
/// Get player ID for this connection
|
||||
pub fn player_id(&self) -> Option<NationId> {
|
||||
self.backend.player_id()
|
||||
}
|
||||
|
||||
/// Try to receive a turn (works for local backend)
|
||||
pub fn try_recv_turn(&self) -> Option<Turn> {
|
||||
self.backend.try_recv_turn()
|
||||
}
|
||||
|
||||
/// Get the turn buffer size for warning messages
|
||||
pub fn turn_buffer_size(&self) -> usize {
|
||||
self.tracker.turn_buffer_size()
|
||||
}
|
||||
|
||||
/// Get mutable access to backend (for downcasting)
|
||||
pub fn backend_mut(&mut self) -> &mut dyn ConnectionBackend {
|
||||
&mut *self.backend
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper trait for downcasting backends
|
||||
pub trait AsRemoteBackend {
|
||||
fn as_remote(&self) -> Option<&RemoteBackend>;
|
||||
fn as_remote_mut(&mut self) -> Option<&mut RemoteBackend>;
|
||||
}
|
||||
|
||||
impl AsRemoteBackend for dyn ConnectionBackend {
|
||||
fn as_remote(&self) -> Option<&RemoteBackend> {
|
||||
// This is a simplified approach - in production you'd use Any trait
|
||||
None
|
||||
}
|
||||
|
||||
fn as_remote_mut(&mut self) -> Option<&mut RemoteBackend> {
|
||||
// This is a simplified approach - in production you'd use Any trait
|
||||
None
|
||||
}
|
||||
}
|
||||
5
crates/borders-core/src/networking/client/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod connection;
|
||||
mod systems;
|
||||
|
||||
pub use connection::*;
|
||||
pub use systems::*;
|
||||
39
crates/borders-core/src/networking/client/systems.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use super::connection::Connection;
|
||||
use crate::{
|
||||
game::{SpawnManager, TerrainData, TerritoryManager},
|
||||
networking::{IntentEvent, SpawnConfigEvent},
|
||||
};
|
||||
use bevy_ecs::prelude::*;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Resource for receiving tracked intents from the client
|
||||
pub type IntentReceiver = super::connection::TrackedIntentReceiver;
|
||||
|
||||
/// Unified intent sending system that works for both local and remote
|
||||
///
|
||||
/// Uses the generic `Connection` resource to abstract transport mechanism.
|
||||
pub fn send_intent_system(mut intent_events: MessageReader<IntentEvent>, mut connection: ResMut<Connection>, game_view: Option<Res<crate::game::view::GameView>>) {
|
||||
let current_turn = game_view.as_ref().map(|gv| gv.turn_number).unwrap_or(0);
|
||||
|
||||
for event in intent_events.read() {
|
||||
debug!("Sending intent: {:?}", event.0);
|
||||
connection.send_intent(event.0.clone(), current_turn);
|
||||
}
|
||||
}
|
||||
|
||||
/// System to handle spawn configuration updates from server
|
||||
/// Updates local SpawnManager with remote player spawn positions
|
||||
pub fn handle_spawn_config_system(mut events: MessageReader<SpawnConfigEvent>, mut spawns: If<ResMut<SpawnManager>>, territory: If<Res<TerritoryManager>>, terrain: If<Res<TerrainData>>) {
|
||||
for event in events.read() {
|
||||
// Update player spawns from server
|
||||
spawns.player_spawns.clear();
|
||||
for (&player_id, &tile_index) in &event.0 {
|
||||
spawns.player_spawns.push(crate::game::SpawnPoint::new(player_id, tile_index));
|
||||
}
|
||||
|
||||
// Recalculate bot spawns based on updated player positions
|
||||
spawns.current_bot_spawns = crate::game::ai::bot::recalculate_spawns_with_players(spawns.initial_bot_spawns.clone(), &spawns.player_spawns, &territory, &terrain, spawns.rng_seed);
|
||||
|
||||
info!("Updated spawn manager with {} player spawns from server", spawns.player_spawns.len());
|
||||
}
|
||||
}
|
||||
16
crates/borders-core/src/networking/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
//! Networking and multiplayer synchronization
|
||||
//!
|
||||
//! This module provides the core networking infrastructure for the game:
|
||||
//! - Shared protocol and data structures
|
||||
//! - Client-side connection and systems
|
||||
//! - Server-side turn generation and coordination
|
||||
|
||||
// Public modules
|
||||
pub mod client;
|
||||
pub mod server;
|
||||
|
||||
// Flattened public modules
|
||||
mod protocol;
|
||||
mod types;
|
||||
pub use protocol::*;
|
||||
pub use types::*;
|
||||
43
crates/borders-core/src/networking/protocol.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
//! Network protocol for multiplayer client-server communication
|
||||
|
||||
use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
|
||||
|
||||
use crate::{game::NationId, networking::Intent};
|
||||
use glam::U16Vec2;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Intent wrapper with source player ID assigned by server
|
||||
///
|
||||
/// The server wraps all intents with the authenticated source player ID
|
||||
/// to prevent client spoofing. The intent_id is echoed back from the
|
||||
/// client for round-trip tracking.
|
||||
#[derive(Debug, Clone, Archive, RkyvSerialize, RkyvDeserialize)]
|
||||
#[rkyv(derive(Debug))]
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct SourcedIntent {
|
||||
/// Authenticated player ID (assigned by server)
|
||||
pub source: NationId,
|
||||
/// Client-assigned ID for tracking (echoed back by server)
|
||||
pub intent_id: u64,
|
||||
/// The actual intent payload
|
||||
pub intent: Intent,
|
||||
}
|
||||
|
||||
/// Network message protocol for client-server communication
|
||||
#[derive(Debug, Clone, Archive, RkyvSerialize, RkyvDeserialize)]
|
||||
#[rkyv(derive(Debug))]
|
||||
pub enum NetMessage {
|
||||
/// Server assigns player ID to client
|
||||
ServerConfig { player_id: NationId },
|
||||
/// Client sends intent to server with tracking ID
|
||||
Intent { id: u64, intent: Intent },
|
||||
/// Server broadcasts turn to all clients with sourced intents
|
||||
Turn { turn: u64, intents: Vec<SourcedIntent> },
|
||||
/// Server broadcasts current spawn configuration during spawn phase
|
||||
/// Maps player_id -> tile_position for all players who have chosen spawns
|
||||
SpawnConfiguration { spawns: HashMap<NationId, U16Vec2> },
|
||||
}
|
||||
|
||||
/// Shared constants across all binaries for deterministic behavior
|
||||
pub const NETWORK_SEED: u64 = 0xC0FFEE;
|
||||
pub const TICK_MS: u64 = 100;
|
||||
152
crates/borders-core/src/networking/server/coordinator.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
use crate::game::terrain::TerrainData;
|
||||
use crate::game::{SpawnManager, TerritoryManager};
|
||||
use crate::time::Time;
|
||||
use bevy_ecs::prelude::*;
|
||||
use tracing::warn;
|
||||
|
||||
use super::turn_generator::{SharedTurnGenerator, TurnOutput};
|
||||
use crate::game::LocalPlayerContext;
|
||||
use crate::networking::{Intent, ProcessTurnEvent, SourcedIntent, Turn};
|
||||
use flume::{Receiver, Sender};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
/// Resource for receiving tracked intents from the client
|
||||
/// This has replaced the old IntentReceiver that used plain Intent
|
||||
pub type IntentReceiver = crate::networking::client::TrackedIntentReceiver;
|
||||
|
||||
#[derive(Resource)]
|
||||
pub struct TurnReceiver {
|
||||
pub turn_rx: Receiver<Turn>,
|
||||
}
|
||||
|
||||
/// Local turn server control handle
|
||||
#[derive(Resource, Clone)]
|
||||
pub struct LocalTurnServerHandle {
|
||||
pub paused: Arc<AtomicBool>,
|
||||
pub running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl LocalTurnServerHandle {
|
||||
pub fn pause(&self) {
|
||||
self.paused.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub fn resume(&self) {
|
||||
self.paused.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub fn stop(&self) {
|
||||
self.running.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub fn is_paused(&self) -> bool {
|
||||
self.paused.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.running.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resource wrapping the shared turn generator and output channel
|
||||
#[derive(Resource)]
|
||||
pub struct TurnGenerator {
|
||||
generator: SharedTurnGenerator,
|
||||
turn_tx: Sender<Turn>,
|
||||
}
|
||||
|
||||
impl TurnGenerator {
|
||||
pub fn new(turn_tx: Sender<Turn>) -> Self {
|
||||
Self { generator: SharedTurnGenerator::new(), turn_tx }
|
||||
}
|
||||
}
|
||||
|
||||
/// System to generate turns using Bevy's Update loop
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn generate_turns_system(mut generator: If<ResMut<TurnGenerator>>, server_handle: If<Res<LocalTurnServerHandle>>, intent_receiver: If<Res<IntentReceiver>>, local_context: Option<Res<LocalPlayerContext>>, mut spawns: Option<ResMut<SpawnManager>>, time: Res<Time>, territory: Option<Res<TerritoryManager>>, terrain: Option<Res<TerrainData>>) {
|
||||
let _guard = tracing::trace_span!("generate_turns").entered();
|
||||
if !server_handle.is_running() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(ref territory) = territory else {
|
||||
return;
|
||||
};
|
||||
|
||||
let is_paused = server_handle.paused.load(Ordering::SeqCst);
|
||||
|
||||
// Get player ID for wrapping intents (local single-player)
|
||||
let Some(local_context) = local_context else {
|
||||
return;
|
||||
};
|
||||
let player_id = local_context.id;
|
||||
|
||||
// During spawn phase (paused), process intents and update SpawnManager
|
||||
if is_paused {
|
||||
while let Ok(tracked_intent) = intent_receiver.rx.try_recv() {
|
||||
// Wrap tracked intent with player_id for local single-player
|
||||
let sourced_intent = SourcedIntent { source: player_id, intent_id: tracked_intent.id, intent: tracked_intent.intent.clone() };
|
||||
|
||||
let output = generator.generator.process_intent(sourced_intent.clone());
|
||||
|
||||
// Update SpawnManager for SetSpawn intents (two-pass spawn system)
|
||||
if let Intent::SetSpawn { tile_index } = sourced_intent.intent
|
||||
&& let Some(ref mut spawns) = spawns
|
||||
&& let Some(ref terrain_data) = terrain
|
||||
{
|
||||
spawns.update_player_spawn(sourced_intent.source, tile_index, territory, terrain_data);
|
||||
}
|
||||
|
||||
// SpawnUpdate output is not used here - SpawnManager handles coordination
|
||||
let _ = output;
|
||||
}
|
||||
|
||||
// Tick the generator to check spawn timeout
|
||||
let delta_ms = time.delta().as_secs_f64() * 1000.0;
|
||||
let output = generator.generator.tick(delta_ms, vec![]);
|
||||
|
||||
// Handle GameStart output
|
||||
if let TurnOutput::GameStart(turn) = output {
|
||||
if let Err(e) = generator.turn_tx.send(turn) {
|
||||
warn!("Failed to send Turn(0): {}", e);
|
||||
}
|
||||
|
||||
server_handle.resume();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal turn generation (after game has started)
|
||||
if !generator.generator.game_started() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect all pending intents and wrap them with player_id
|
||||
let mut sourced_intents = Vec::new();
|
||||
while let Ok(tracked_intent) = intent_receiver.rx.try_recv() {
|
||||
sourced_intents.push(SourcedIntent { source: player_id, intent_id: tracked_intent.id, intent: tracked_intent.intent });
|
||||
}
|
||||
|
||||
// Tick the generator with accumulated time and sourced intents
|
||||
let delta_ms = time.delta().as_secs_f64() * 1000.0;
|
||||
let output = generator.generator.tick(delta_ms, sourced_intents);
|
||||
|
||||
// Handle Turn output
|
||||
if let TurnOutput::Turn(turn) = output
|
||||
&& let Err(e) = generator.turn_tx.send(turn)
|
||||
{
|
||||
warn!("Failed to send turn: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// System to poll for turns from the local server and emit ProcessTurnEvent
|
||||
pub fn poll_turns_system(turn_receiver: If<Res<TurnReceiver>>, mut process_turn_writer: MessageWriter<ProcessTurnEvent>) {
|
||||
let _guard = tracing::trace_span!("poll_turns").entered();
|
||||
while let Ok(turn) = turn_receiver.turn_rx.try_recv() {
|
||||
process_turn_writer.write(ProcessTurnEvent(turn));
|
||||
}
|
||||
}
|
||||
11
crates/borders-core/src/networking/server/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
//! Server-side networking components
|
||||
//!
|
||||
//! This module contains server and local-mode server code:
|
||||
//! - Turn generation and coordination
|
||||
//! - Local server control (for single-player mode)
|
||||
|
||||
mod coordinator;
|
||||
mod turn_generator;
|
||||
|
||||
pub use coordinator::*;
|
||||
pub use turn_generator::*;
|
||||
222
crates/borders-core/src/networking/server/turn_generator.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
use crate::game::NationId;
|
||||
use crate::networking::{Intent, SourcedIntent, Turn};
|
||||
use glam::U16Vec2;
|
||||
use std::collections::HashMap;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Spawn timeout duration (milliseconds)
|
||||
const SPAWN_TIMEOUT_MS: f64 = 5000.0;
|
||||
|
||||
/// Output from the turn generator
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TurnOutput {
|
||||
/// No output this tick
|
||||
None,
|
||||
/// Spawn configuration was updated
|
||||
SpawnUpdate(HashMap<NationId, U16Vec2>),
|
||||
/// Game starting with Turn 0
|
||||
GameStart(Turn),
|
||||
/// Regular game turn
|
||||
Turn(Turn),
|
||||
}
|
||||
|
||||
/// Shared turn generation logic for both local coordinator and relay server
|
||||
pub struct SharedTurnGenerator {
|
||||
turn_number: u64,
|
||||
accumulated_time: f64, // milliseconds
|
||||
spawn_config: HashMap<NationId, U16Vec2>,
|
||||
spawn_timeout_accumulated: Option<f64>, // milliseconds since first spawn
|
||||
game_started: bool,
|
||||
}
|
||||
|
||||
impl SharedTurnGenerator {
|
||||
pub fn new() -> Self {
|
||||
Self { turn_number: 0, accumulated_time: 0.0, spawn_config: HashMap::new(), spawn_timeout_accumulated: None, game_started: false }
|
||||
}
|
||||
|
||||
/// Process a single sourced intent, returns output if spawn config changed
|
||||
pub fn process_intent(&mut self, sourced_intent: SourcedIntent) -> TurnOutput {
|
||||
match sourced_intent.intent {
|
||||
Intent::SetSpawn { tile_index } => {
|
||||
if self.game_started {
|
||||
warn!("Received SetSpawn intent after game started - ignoring");
|
||||
return TurnOutput::None;
|
||||
}
|
||||
|
||||
let player_id = sourced_intent.source;
|
||||
debug!("Player {} set spawn at tile {}", player_id, tile_index);
|
||||
self.spawn_config.insert(player_id, tile_index);
|
||||
|
||||
// Start timeout on first spawn
|
||||
if self.spawn_timeout_accumulated.is_none() {
|
||||
self.spawn_timeout_accumulated = Some(0.0);
|
||||
debug!("Spawn timeout started ({}ms)", SPAWN_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
TurnOutput::SpawnUpdate(self.spawn_config.clone())
|
||||
}
|
||||
Intent::Action(_) => {
|
||||
if !self.game_started {
|
||||
warn!("Received Action intent during spawn phase - ignoring");
|
||||
}
|
||||
TurnOutput::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tick with delta time (ms), returns turn if ready
|
||||
/// During spawn phase, checks timeout. During game phase, accumulates time and generates turns.
|
||||
pub fn tick(&mut self, delta_ms: f64, sourced_intents: Vec<SourcedIntent>) -> TurnOutput {
|
||||
// During spawn phase, handle timeout
|
||||
if !self.game_started {
|
||||
if let Some(ref mut accumulated) = self.spawn_timeout_accumulated {
|
||||
*accumulated += delta_ms;
|
||||
|
||||
// Check if timeout expired
|
||||
if *accumulated >= SPAWN_TIMEOUT_MS {
|
||||
debug!("Spawn timeout expired - starting game");
|
||||
|
||||
// Create Turn(0) to start game
|
||||
let start_turn = Turn { turn_number: 0, intents: Vec::new() };
|
||||
|
||||
info!("Turn(0) ready to start game (spawns already configured)");
|
||||
|
||||
// Mark game as started and clear spawn phase
|
||||
self.game_started = true;
|
||||
self.spawn_config.clear();
|
||||
self.spawn_timeout_accumulated = None;
|
||||
self.turn_number = 1; // Next turn will be turn 1
|
||||
self.accumulated_time = 0.0; // Reset for clean turn timing
|
||||
|
||||
info!("Spawn phase complete - game started, next turn will be Turn 1");
|
||||
|
||||
return TurnOutput::GameStart(start_turn);
|
||||
}
|
||||
}
|
||||
|
||||
return TurnOutput::None;
|
||||
}
|
||||
|
||||
// Normal turn generation (after game has started)
|
||||
self.accumulated_time += delta_ms;
|
||||
|
||||
// Only generate turn if enough time has passed (100ms tick interval)
|
||||
if self.accumulated_time < 100.0 {
|
||||
return TurnOutput::None;
|
||||
}
|
||||
|
||||
// Reset accumulated time
|
||||
self.accumulated_time -= 100.0;
|
||||
|
||||
// Filter out SetSpawn intents (they're ignored after game starts)
|
||||
let action_intents: Vec<SourcedIntent> = sourced_intents
|
||||
.into_iter()
|
||||
.filter_map(|sourced_intent| match sourced_intent.intent {
|
||||
Intent::Action(_) => Some(sourced_intent),
|
||||
Intent::SetSpawn { .. } => {
|
||||
warn!("Received SetSpawn intent after game started - ignoring");
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Create turn
|
||||
let turn = Turn { turn_number: self.turn_number, intents: action_intents };
|
||||
|
||||
self.turn_number += 1;
|
||||
|
||||
TurnOutput::Turn(turn)
|
||||
}
|
||||
|
||||
/// Get current turn number
|
||||
pub fn turn_number(&self) -> u64 {
|
||||
self.turn_number
|
||||
}
|
||||
|
||||
/// Check if game has started
|
||||
pub fn game_started(&self) -> bool {
|
||||
self.game_started
|
||||
}
|
||||
|
||||
/// Get current spawn configuration
|
||||
pub fn spawn_config(&self) -> &HashMap<NationId, U16Vec2> {
|
||||
&self.spawn_config
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SharedTurnGenerator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game::core::action::GameAction;
|
||||
|
||||
#[test]
|
||||
fn test_spawn_phase_timeout() {
|
||||
let mut generator = SharedTurnGenerator::new();
|
||||
|
||||
// Process a spawn intent
|
||||
let sourced_intent = SourcedIntent { source: NationId::new_unchecked(1), intent_id: 1, intent: Intent::SetSpawn { tile_index: U16Vec2::new(5, 5) } };
|
||||
let output = generator.process_intent(sourced_intent);
|
||||
assert!(matches!(output, TurnOutput::SpawnUpdate(_)));
|
||||
|
||||
// Tick with time less than timeout
|
||||
let output = generator.tick(2000.0, vec![]);
|
||||
assert!(matches!(output, TurnOutput::None));
|
||||
|
||||
// Tick with time to exceed timeout
|
||||
let output = generator.tick(3500.0, vec![]);
|
||||
assert!(matches!(output, TurnOutput::GameStart(_)));
|
||||
assert!(generator.game_started());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_turn_generation() {
|
||||
let mut generator = SharedTurnGenerator::new();
|
||||
|
||||
// Start game by setting spawn and waiting for timeout
|
||||
generator.process_intent(SourcedIntent { source: NationId::new_unchecked(1), intent_id: 1, intent: Intent::SetSpawn { tile_index: U16Vec2::new(5, 5) } });
|
||||
generator.tick(6000.0, vec![]); // Trigger game start
|
||||
|
||||
// Tick with time less than tick interval
|
||||
let output = generator.tick(50.0, vec![]);
|
||||
assert!(matches!(output, TurnOutput::None));
|
||||
|
||||
// Tick with time to exceed tick interval
|
||||
let sourced_intents = vec![SourcedIntent { source: NationId::new_unchecked(1), intent_id: 2, intent: Intent::Action(GameAction::Attack { target: Some(NationId::new_unchecked(2)), troops: 100 }) }];
|
||||
let output = generator.tick(60.0, sourced_intents);
|
||||
if let TurnOutput::Turn(turn) = output {
|
||||
assert_eq!(turn.turn_number, 1);
|
||||
assert_eq!(turn.intents.len(), 1);
|
||||
} else {
|
||||
panic!("Expected TurnOutput::Turn");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ignore_action_during_spawn() {
|
||||
let mut generator = SharedTurnGenerator::new();
|
||||
|
||||
let sourced_intent = SourcedIntent { source: NationId::new_unchecked(1), intent_id: 1, intent: Intent::Action(GameAction::Attack { target: None, troops: 50 }) };
|
||||
let output = generator.process_intent(sourced_intent);
|
||||
assert!(matches!(output, TurnOutput::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ignore_spawn_after_game_start() {
|
||||
let mut generator = SharedTurnGenerator::new();
|
||||
|
||||
// Start game
|
||||
generator.process_intent(SourcedIntent { source: NationId::new_unchecked(1), intent_id: 1, intent: Intent::SetSpawn { tile_index: U16Vec2::new(5, 5) } });
|
||||
generator.tick(6000.0, vec![]); // Trigger game start
|
||||
|
||||
// Try to set spawn after game started
|
||||
let sourced_intent = SourcedIntent { source: NationId::new_unchecked(2), intent_id: 2, intent: Intent::SetSpawn { tile_index: U16Vec2::new(10, 10) } };
|
||||
let output = generator.process_intent(sourced_intent);
|
||||
assert!(matches!(output, TurnOutput::None));
|
||||
}
|
||||
}
|
||||
56
crates/borders-core/src/networking/types.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
//! Shared networking types and events
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy_ecs::prelude::Message;
|
||||
use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::protocol::SourcedIntent;
|
||||
use crate::{game::NationId, game::core::action::GameAction};
|
||||
|
||||
// Shared event types
|
||||
#[derive(Message, Debug, Clone, Archive, RkyvSerialize, RkyvDeserialize)]
|
||||
#[rkyv(derive(Debug))]
|
||||
pub struct IntentEvent(pub Intent);
|
||||
|
||||
#[derive(Message, Debug, Clone, Archive, RkyvSerialize, RkyvDeserialize)]
|
||||
#[rkyv(derive(Debug))]
|
||||
pub struct ProcessTurnEvent(pub Turn);
|
||||
|
||||
/// Event containing spawn configuration update from server (multiplayer)
|
||||
#[derive(Message, Debug, Clone)]
|
||||
pub struct SpawnConfigEvent(pub HashMap<NationId, glam::U16Vec2>);
|
||||
|
||||
/// Network wrapper for player intents
|
||||
///
|
||||
/// Intent is the network-layer representation of player intents.
|
||||
/// It has two variants:
|
||||
/// - Action: State-recorded game actions that appear in game history (replays)
|
||||
/// - SetSpawn: Ephemeral spawn selection that doesn't pollute game history
|
||||
///
|
||||
/// Note: Bot actions are NOT sent as intents - they are calculated
|
||||
/// deterministically on each client during turn execution.
|
||||
///
|
||||
/// Player identity is derived from the connection (server-side) and wrapped
|
||||
/// in SourcedIntent to prevent spoofing.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Archive, RkyvSerialize, RkyvDeserialize)]
|
||||
#[rkyv(derive(Debug))]
|
||||
pub enum Intent {
|
||||
/// State-recorded game action (appears in game history for replays)
|
||||
Action(GameAction),
|
||||
/// Ephemeral spawn selection (not recorded in history)
|
||||
/// Only valid during spawn phase, ignored after game starts
|
||||
/// Player ID is derived from connection by server
|
||||
SetSpawn {
|
||||
#[serde(with = "crate::game::utils::u16vec2_serde")]
|
||||
tile_index: glam::U16Vec2,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Archive, RkyvSerialize, RkyvDeserialize)]
|
||||
#[rkyv(derive(Debug))]
|
||||
pub struct Turn {
|
||||
pub turn_number: u64,
|
||||
pub intents: Vec<SourcedIntent>,
|
||||
}
|
||||
21
crates/borders-core/src/platform.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use std::future::Future;
|
||||
|
||||
/// Spawn an async task on the appropriate runtime for the platform.
|
||||
///
|
||||
/// On native targets, uses tokio::spawn for multi-threaded execution.
|
||||
/// On WASM targets, uses wasm_bindgen_futures::spawn_local for browser integration.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn spawn_task<F>(future: F)
|
||||
where
|
||||
F: Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
tokio::spawn(future);
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn spawn_task<F>(future: F)
|
||||
where
|
||||
F: Future<Output = ()> + 'static,
|
||||
{
|
||||
wasm_bindgen_futures::spawn_local(future);
|
||||
}
|
||||
801
crates/borders-core/src/plugin.rs
Normal file
@@ -0,0 +1,801 @@
|
||||
//! Consolidated game plugin integrating all core systems
|
||||
//!
|
||||
//! This module provides the main `GamePlugin` which sets up all game logic including:
|
||||
//! - Networking (local or remote)
|
||||
//! - Spawn phase management
|
||||
//! - Core game systems and event handling
|
||||
//! - Turn execution and processing
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use bevy_ecs::hierarchy::ChildOf;
|
||||
use bevy_ecs::prelude::*;
|
||||
use bevy_ecs::system::SystemParam;
|
||||
use tracing::{debug, info, trace};
|
||||
|
||||
use crate::app::{App, Last, Plugin, Update};
|
||||
use crate::game::ships::{LaunchShipEvent, ShipArrivalEvent, handle_ship_arrivals_system, launch_ship_system, update_ships_system};
|
||||
use crate::game::{BorderCache, NationId, TerrainData, clear_territory_changes_system};
|
||||
use crate::networking::SpawnConfigEvent;
|
||||
use crate::networking::server::{LocalTurnServerHandle, TurnGenerator};
|
||||
use crate::networking::{IntentEvent, ProcessTurnEvent};
|
||||
use crate::time::{FixedTime, Time};
|
||||
|
||||
use crate::game::core::constants::game::TICK_INTERVAL;
|
||||
use crate::game::view::GameView;
|
||||
use crate::game::{AttackControls, CurrentTurn, SpawnPhase, SpawnTimeout, check_local_player_outcome, handle_attack_click_system, handle_attack_ratio_keys_system, handle_center_camera_system, handle_spawn_click_system, process_player_income_system, turn_is_ready, update_player_borders_system};
|
||||
use crate::networking::client::IntentReceiver;
|
||||
use crate::networking::server::{TurnReceiver, generate_turns_system, poll_turns_system};
|
||||
|
||||
#[cfg(feature = "ui")]
|
||||
use crate::ui::{emit_attacks_update_system, emit_leaderboard_snapshot_system, emit_nation_highlight_system, emit_ships_update_system};
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use web_time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// Re-export protocol types for convenience
|
||||
#[cfg(feature = "ui")]
|
||||
use crate::ui::protocol::{BackendMessage, SpawnCountdown};
|
||||
|
||||
/// Network mode configuration for the game
|
||||
pub enum NetworkMode {
|
||||
/// Local single-player or hotseat mode
|
||||
Local,
|
||||
/// Remote multiplayer mode (non-WASM only)
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
Remote { server_address: String },
|
||||
}
|
||||
|
||||
/// Main game plugin that consolidates all core game logic
|
||||
///
|
||||
/// This plugin sets up:
|
||||
/// - Network channels (local or remote)
|
||||
/// - Spawn phase management
|
||||
/// - Core game systems
|
||||
/// - Turn processing
|
||||
/// - Input handling
|
||||
pub struct GamePlugin {
|
||||
pub network_mode: NetworkMode,
|
||||
}
|
||||
|
||||
impl GamePlugin {
|
||||
pub fn new(network_mode: NetworkMode) -> Self {
|
||||
Self { network_mode }
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for GamePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let _guard = tracing::debug_span!("game_plugin_build").entered();
|
||||
|
||||
// Setup networking based on mode
|
||||
match &self.network_mode {
|
||||
NetworkMode::Local => {
|
||||
let _guard = tracing::trace_span!("network_setup", mode = "local").entered();
|
||||
info!("Initializing GamePlugin in Local mode");
|
||||
|
||||
// Local mode: use tracked intent channel for turn coordination
|
||||
let (tracked_intent_tx, tracked_intent_rx) = flume::unbounded();
|
||||
|
||||
// Note: Turn channels (turn_tx/turn_rx) are created later in initialize_game_resources
|
||||
// when StartGame is called. Connection doesn't need turn_rx yet.
|
||||
|
||||
// Create a placeholder receiver that will be replaced when game starts
|
||||
let (_placeholder_tx, placeholder_rx) = flume::unbounded();
|
||||
|
||||
// Create LocalBackend for single-player mode
|
||||
let backend = crate::networking::client::LocalBackend::new(
|
||||
tracked_intent_tx,
|
||||
placeholder_rx,
|
||||
NationId::ZERO, // Local player is always NationId::ZERO
|
||||
);
|
||||
|
||||
// Create Connection resource with LocalBackend
|
||||
let connection = crate::networking::client::Connection::new_local(backend);
|
||||
|
||||
app.insert_resource(connection).insert_resource(IntentReceiver { rx: tracked_intent_rx }).add_systems(Update, (poll_turns_system, crate::networking::client::send_intent_system)).add_systems(Last, clear_territory_changes_system);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
NetworkMode::Remote { server_address: _ } => {
|
||||
// TODO: Remote networking currently disabled due to bincode incompatibility with glam types
|
||||
unimplemented!("Remote networking temporarily disabled");
|
||||
/*
|
||||
let _guard = tracing::trace_span!("network_setup", mode = "remote", server = %server_address).entered();
|
||||
info!(
|
||||
"Initializing GamePlugin in Remote mode (server: {})",
|
||||
server_address
|
||||
);
|
||||
|
||||
// Remote mode: use NetMessage protocol
|
||||
let (net_intent_tx, net_intent_rx) = flume::unbounded();
|
||||
let (net_message_tx, net_message_rx) = flume::unbounded();
|
||||
|
||||
app.insert_resource(crate::networking::client::RemoteClientConnection {
|
||||
intent_tx: net_intent_tx,
|
||||
net_message_rx,
|
||||
player_id: None,
|
||||
})
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
crate::networking::client::systems::send_net_intent_system,
|
||||
crate::networking::client::systems::receive_net_message_system,
|
||||
crate::networking::client::systems::handle_spawn_config_system,
|
||||
),
|
||||
);
|
||||
|
||||
// Spawn networking thread
|
||||
let server_addr = server_address.clone();
|
||||
let server_addr_span = server_addr.clone();
|
||||
std::thread::spawn(move || {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
runtime.block_on(
|
||||
async move {
|
||||
use crate::networking::protocol::NetMessage;
|
||||
use tracing::error;
|
||||
|
||||
info!("Connecting to remote server at {}", server_addr);
|
||||
|
||||
// Load server certificate for validation
|
||||
let cert_path = "dev-cert.pem";
|
||||
let cert_data = match std::fs::read(cert_path) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
error!("Failed to read certificate file {}: {}", cert_path, e);
|
||||
error!("Please run the `generate-dev-cert.ps1` script first.");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let pem =
|
||||
pem::parse(&cert_data).expect("Failed to parse PEM certificate");
|
||||
let cert_hash =
|
||||
ring::digest::digest(&ring::digest::SHA256, pem.contents())
|
||||
.as_ref()
|
||||
.to_vec();
|
||||
|
||||
let client = web_transport::ClientBuilder::new()
|
||||
.with_server_certificate_hashes(vec![cert_hash])
|
||||
.expect("Failed to create client with certificate hash");
|
||||
|
||||
let mut connection =
|
||||
match client.connect(server_addr.parse().unwrap()).await {
|
||||
Ok(conn) => {
|
||||
info!("Connected to server successfully");
|
||||
conn
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to connect to server: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let (mut send_stream, mut recv_stream) =
|
||||
match connection.open_bi().await {
|
||||
Ok(streams) => {
|
||||
info!("Opened bidirectional stream");
|
||||
streams
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to open bidirectional stream: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Read initial ServerConfig
|
||||
info!("Reading initial server config...");
|
||||
let mut len_bytes = Vec::new();
|
||||
while len_bytes.len() < 8 {
|
||||
let remaining = 8 - len_bytes.len();
|
||||
match recv_stream.read(remaining).await {
|
||||
Ok(Some(chunk)) => {
|
||||
len_bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(None) => {
|
||||
error!("Stream closed before reading server config length");
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to read server config length: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let len =
|
||||
u64::from_be_bytes(len_bytes[0..8].try_into().unwrap()) as usize;
|
||||
|
||||
let mut message_bytes = Vec::new();
|
||||
while message_bytes.len() < len {
|
||||
let remaining = len - message_bytes.len();
|
||||
match recv_stream.read(remaining).await {
|
||||
Ok(Some(chunk)) => {
|
||||
message_bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(None) => {
|
||||
error!("Stream closed before reading server config data");
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to read server config data: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match bincode::decode_from_slice(
|
||||
&message_bytes,
|
||||
bincode::config::standard(),
|
||||
) {
|
||||
Ok((net_message, _)) => {
|
||||
info!("Received server config: {:?}", net_message);
|
||||
match net_message {
|
||||
NetMessage::ServerConfig { player_id } => {
|
||||
info!("Assigned player ID: {}", player_id);
|
||||
}
|
||||
_ => {
|
||||
error!("Expected ServerConfig, got: {:?}", net_message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to decode server config: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Send intents to server
|
||||
let send_task = async {
|
||||
while let Ok(net_message) = net_intent_rx.recv_async().await {
|
||||
match bincode::encode_to_vec(
|
||||
net_message,
|
||||
bincode::config::standard(),
|
||||
) {
|
||||
Ok(message_bytes) => {
|
||||
let len_bytes =
|
||||
(message_bytes.len() as u64).to_be_bytes();
|
||||
|
||||
let mut written = 0;
|
||||
while written < len_bytes.len() {
|
||||
match send_stream.write(&len_bytes[written..]).await
|
||||
{
|
||||
Ok(bytes_written) => written += bytes_written,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to send length prefix: {}",
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut written = 0;
|
||||
while written < message_bytes.len() {
|
||||
match send_stream
|
||||
.write(&message_bytes[written..])
|
||||
.await
|
||||
{
|
||||
Ok(bytes_written) => written += bytes_written,
|
||||
Err(e) => {
|
||||
error!("Failed to send message: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to encode message: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Receive messages from server
|
||||
let recv_task = async {
|
||||
loop {
|
||||
let mut len_bytes = Vec::new();
|
||||
while len_bytes.len() < 8 {
|
||||
let remaining = 8 - len_bytes.len();
|
||||
if let Ok(maybe_chunk) = recv_stream.read(remaining).await {
|
||||
if let Some(chunk) = maybe_chunk {
|
||||
len_bytes.extend_from_slice(&chunk);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
error!("Stream closed before reading length prefix");
|
||||
break;
|
||||
}
|
||||
}
|
||||
let len =
|
||||
u64::from_be_bytes(len_bytes[0..8].try_into().unwrap())
|
||||
as usize;
|
||||
|
||||
let mut message_bytes = Vec::new();
|
||||
while message_bytes.len() < len {
|
||||
let remaining = len - message_bytes.len();
|
||||
if let Ok(maybe_chunk) = recv_stream.read(remaining).await {
|
||||
if let Some(chunk) = maybe_chunk {
|
||||
message_bytes.extend_from_slice(&chunk);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
error!("Stream closed before reading full message");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
match bincode::decode_from_slice(
|
||||
&message_bytes,
|
||||
bincode::config::standard(),
|
||||
) {
|
||||
Ok((net_message, _)) => {
|
||||
if net_message_tx.send_async(net_message).await.is_err()
|
||||
{
|
||||
error!("Failed to forward message to client");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to decode message: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
futures_lite::future::zip(send_task, recv_task).await;
|
||||
error!("Connection to server closed");
|
||||
}
|
||||
.instrument(
|
||||
tracing::trace_span!("remote_connection", server = %server_addr_span),
|
||||
),
|
||||
);
|
||||
});
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
// Configure fixed timestep for game logic (10 TPS = 100ms)
|
||||
app.insert_resource(FixedTime::from_seconds(TICK_INTERVAL as f64 / 1000.0));
|
||||
|
||||
// Core multiplayer events and resources
|
||||
app.add_message::<IntentEvent>().add_message::<ProcessTurnEvent>().add_message::<SpawnConfigEvent>().add_message::<LaunchShipEvent>().add_message::<ShipArrivalEvent>().init_resource::<GameView>();
|
||||
|
||||
// UI-related events and resources (feature-gated)
|
||||
#[cfg(feature = "ui")]
|
||||
{
|
||||
use crate::ui::{DisplayOrderUpdateCounter, LastAttacksDigest, LastDisplayOrder, LastLeaderboardDigest, LeaderboardThrottle, NationHighlightState, ShipStateTracker};
|
||||
|
||||
app.init_resource::<LastLeaderboardDigest>().init_resource::<LastAttacksDigest>().init_resource::<LeaderboardThrottle>().init_resource::<DisplayOrderUpdateCounter>().init_resource::<LastDisplayOrder>().init_resource::<NationHighlightState>().init_resource::<ShipStateTracker>();
|
||||
}
|
||||
|
||||
// Input-related resources
|
||||
app.init_resource::<SpawnPhase>().init_resource::<AttackControls>().init_resource::<BorderCache>();
|
||||
|
||||
// Spawn phase management
|
||||
app.init_resource::<SpawnPhaseInitialized>().init_resource::<PreviousSpawnState>().add_systems(Update, (emit_initial_spawn_phase_system, manage_spawn_phase_system, update_spawn_preview_system));
|
||||
|
||||
// Core game logic systems (run in Update, event-driven)
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
// Step 1: Receive turn events and update CurrentTurn resource
|
||||
update_current_turn_system,
|
||||
// Step 2: Execute gameplay systems only when turn is ready (10 TPS)
|
||||
process_player_income_system.run_if(turn_is_ready),
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
|
||||
app.add_systems(Update, (execute_turn_gameplay_system, update_ship_views_system, launch_ship_system, update_ships_system, handle_ship_arrivals_system, check_local_player_outcome, update_player_borders_system).chain().run_if(turn_is_ready));
|
||||
|
||||
// UI update systems (feature-gated)
|
||||
#[cfg(feature = "ui")]
|
||||
app.add_systems(Update, (emit_leaderboard_snapshot_system, emit_attacks_update_system, emit_ships_update_system, emit_nation_highlight_system));
|
||||
|
||||
// Command handlers
|
||||
#[cfg(feature = "ui")]
|
||||
app.add_systems(Update, handle_frontend_messages_system);
|
||||
|
||||
// Platform-agnostic input systems
|
||||
app.add_systems(Update, (handle_spawn_click_system, handle_attack_click_system, handle_center_camera_system, handle_attack_ratio_keys_system));
|
||||
|
||||
// Input state frame update
|
||||
app.add_systems(Last, clear_input_state_system);
|
||||
|
||||
// Turn generation system
|
||||
app.add_systems(Update, generate_turns_system);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resource to track if we've emitted the initial spawn phase event
|
||||
#[derive(Resource, Default)]
|
||||
struct SpawnPhaseInitialized {
|
||||
emitted_initial: bool,
|
||||
}
|
||||
|
||||
/// Resource to track previous spawn state for incremental updates
|
||||
#[derive(Resource, Default)]
|
||||
struct PreviousSpawnState {
|
||||
spawns: Vec<crate::game::SpawnPoint>,
|
||||
}
|
||||
|
||||
/// System to emit initial SpawnPhaseUpdate when game starts
|
||||
#[cfg(feature = "ui")]
|
||||
fn emit_initial_spawn_phase_system(mut initialized: If<ResMut<SpawnPhaseInitialized>>, spawn_phase: If<Res<SpawnPhase>>, territory_manager: Option<Res<crate::game::TerritoryManager>>, mut backend_messages: MessageWriter<BackendMessage>) {
|
||||
if initialized.emitted_initial || !spawn_phase.active || territory_manager.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
backend_messages.write(BackendMessage::SpawnPhaseUpdate { countdown: None });
|
||||
initialized.emitted_initial = true;
|
||||
debug!("Emitted initial SpawnPhaseUpdate (no countdown)");
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "ui"))]
|
||||
fn emit_initial_spawn_phase_system() {}
|
||||
|
||||
/// System to manage spawn timeout and emit countdown updates
|
||||
#[cfg(feature = "ui")]
|
||||
fn manage_spawn_phase_system(mut spawn_timeout: If<ResMut<SpawnTimeout>>, spawn_phase: If<Res<SpawnPhase>>, time: Res<Time>, mut backend_messages: MessageWriter<BackendMessage>) {
|
||||
if !spawn_phase.active || !spawn_timeout.active {
|
||||
return;
|
||||
}
|
||||
|
||||
spawn_timeout.update(time.delta_secs());
|
||||
|
||||
let started_at_ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64 - (spawn_timeout.elapsed_secs * 1000.0) as u64;
|
||||
|
||||
backend_messages.write(BackendMessage::SpawnPhaseUpdate { countdown: Some(SpawnCountdown { started_at_ms, duration_secs: spawn_timeout.duration_secs }) });
|
||||
|
||||
trace!("SpawnPhaseUpdate: remaining {:.1}s", spawn_timeout.remaining_secs);
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "ui"))]
|
||||
fn manage_spawn_phase_system() {}
|
||||
|
||||
/// System to update GameView with spawn preview during spawn phase
|
||||
/// This shows territory data to the frontend BEFORE Turn(0) is executed
|
||||
/// Only processes changed spawns incrementally for better performance
|
||||
fn update_spawn_preview_system(spawn_phase: If<Res<SpawnPhase>>, spawns: If<Res<crate::game::SpawnManager>>, mut game_view: If<ResMut<GameView>>, territory: If<Res<crate::game::TerritoryManager>>, terrain: If<Res<TerrainData>>, mut previous_state: ResMut<PreviousSpawnState>) {
|
||||
if !spawn_phase.active {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only update if SpawnManager has changed
|
||||
if !spawns.is_changed() {
|
||||
return;
|
||||
}
|
||||
|
||||
let size = game_view.size();
|
||||
let current_spawns = spawns.get_all_spawns();
|
||||
|
||||
// Find spawns that were removed and added
|
||||
let previous_spawns = &previous_state.spawns;
|
||||
let removed_spawns: Vec<_> = previous_spawns.iter().filter(|prev| !current_spawns.contains(prev)).copied().collect();
|
||||
let added_spawns: Vec<_> = current_spawns.iter().filter(|curr| !previous_spawns.contains(curr)).copied().collect();
|
||||
|
||||
// If nothing changed, return early
|
||||
if removed_spawns.is_empty() && added_spawns.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start from empty base state (TerritoryManager is empty during spawn phase)
|
||||
// We build spawn preview from scratch each time
|
||||
let base_territories = territory.as_slice();
|
||||
let old_territories: Vec<_> = game_view.territories.iter().copied().collect();
|
||||
let mut territories: Vec<_> = base_territories.to_vec();
|
||||
|
||||
// Apply all current spawns to get final state
|
||||
// This is simpler than tracking incremental changes and handles overlaps correctly
|
||||
for spawn in ¤t_spawns {
|
||||
crate::game::systems::spawn_territory::claim_spawn_territory(spawn.tile, spawn.nation, &mut territories, &terrain, size);
|
||||
}
|
||||
|
||||
// Compute changed tiles by comparing old GameView state with new state
|
||||
// This ensures frontend receives updates for both cleared and newly claimed tiles
|
||||
let changed_tile_indices: Vec<u32> = territories
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, &new_ownership)| {
|
||||
let old_ownership = old_territories.get(idx).copied().unwrap_or(crate::game::TileOwnership::Unclaimed);
|
||||
if old_ownership != new_ownership { Some(idx as u32) } else { None }
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Update game view territories
|
||||
game_view.territories = Arc::from(territories.as_slice());
|
||||
game_view.changed_tiles = changed_tile_indices;
|
||||
|
||||
// Recalculate player tile counts from scratch (simple and correct)
|
||||
update_player_tile_counts(&mut game_view, &territories);
|
||||
|
||||
// Update previous state
|
||||
previous_state.spawns = current_spawns.clone();
|
||||
|
||||
debug!("Spawn preview: {} removed, {} added, {} changed tiles (sample: {:?})", removed_spawns.len(), added_spawns.len(), game_view.changed_tiles.len(), &game_view.changed_tiles[..game_view.changed_tiles.len().min(10)]);
|
||||
}
|
||||
|
||||
/// Recalculates player tile counts from territory ownership data
|
||||
fn update_player_tile_counts(game_view: &mut GameView, territories: &[crate::game::TileOwnership]) {
|
||||
// Count tiles per nation
|
||||
let mut tile_counts: HashMap<NationId, u32> = HashMap::new();
|
||||
for ownership in territories {
|
||||
if let Some(nation_id) = ownership.nation_id() {
|
||||
*tile_counts.entry(nation_id).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Update player views
|
||||
for player in &mut game_view.players {
|
||||
player.tile_count = tile_counts.get(&player.id).copied().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// System to clear per-frame input state data
|
||||
fn clear_input_state_system(input: Option<NonSend<Arc<Mutex<crate::ui::input::InputState>>>>) {
|
||||
if let Some(input) = input
|
||||
&& let Ok(mut state) = input.lock()
|
||||
{
|
||||
state.clear_frame_data();
|
||||
}
|
||||
}
|
||||
|
||||
/// System to receive turn events and update CurrentTurn resource
|
||||
pub fn update_current_turn_system(mut turn_events: MessageReader<ProcessTurnEvent>, mut current_turn: Option<ResMut<CurrentTurn>>, mut commands: Commands) {
|
||||
// Read all turn events (should only be one per frame at 10 TPS)
|
||||
let turns: Vec<_> = turn_events.read().map(|e| e.0.clone()).collect();
|
||||
|
||||
if turns.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Take the latest turn (in case multiple arrived, though this shouldn't happen)
|
||||
let turn = turns.into_iter().last().unwrap();
|
||||
|
||||
if let Some(ref mut current_turn_res) = current_turn {
|
||||
// Update existing resource
|
||||
current_turn_res.turn = turn;
|
||||
current_turn_res.processed = false; // Mark as ready for processing
|
||||
} else {
|
||||
// Initialize resource on first turn
|
||||
commands.insert_resource(CurrentTurn::new(turn));
|
||||
}
|
||||
}
|
||||
|
||||
/// SystemParam to group read-only game resources
|
||||
#[derive(SystemParam)]
|
||||
pub struct GameResources<'w> {
|
||||
border_cache: Res<'w, crate::game::BorderCache>,
|
||||
player_entity_map: Res<'w, crate::game::PlayerEntityMap>,
|
||||
human_count: Res<'w, crate::game::HumanPlayerCount>,
|
||||
terrain: If<Res<'w, TerrainData>>,
|
||||
}
|
||||
|
||||
/// Execute turn gameplay logic
|
||||
/// Only runs when turn_is_ready() returns true (once per turn at 10 TPS)
|
||||
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
||||
pub fn execute_turn_gameplay_system(mut current_turn: ResMut<CurrentTurn>, mut game_view: Option<ResMut<GameView>>, mut territory_manager: If<ResMut<crate::game::TerritoryManager>>, mut active_attacks: ResMut<crate::game::ActiveAttacks>, mut rng: ResMut<crate::game::DeterministicRng>, spawn_manager: Option<Res<crate::game::SpawnManager>>, mut spawn_phase: ResMut<SpawnPhase>, #[cfg(feature = "ui")] mut backend_messages: MessageWriter<BackendMessage>, server_handle: Option<Res<LocalTurnServerHandle>>, resources: GameResources, mut player_queries: ParamSet<(Query<(&mut crate::game::Troops, &mut crate::game::TerritorySize)>, Query<(&crate::game::world::NationId, &crate::game::PlayerName, &crate::game::PlayerColor, &crate::game::Troops, &crate::game::TerritorySize), Without<crate::game::Dead>>, Query<(&crate::game::world::NationId, &crate::game::Troops, &crate::game::TerritorySize, &mut crate::game::ai::bot::Bot), Without<crate::game::Dead>>)>, mut launch_ship_writer: MessageWriter<LaunchShipEvent>, mut commands: Commands) {
|
||||
use std::sync::Arc;
|
||||
|
||||
let Some(ref mut game_view) = game_view else {
|
||||
return;
|
||||
};
|
||||
|
||||
let turn = ¤t_turn.turn;
|
||||
|
||||
let _guard = tracing::trace_span!("execute_turn_gameplay", turn_number = turn.turn_number, intent_count = turn.intents.len()).entered();
|
||||
|
||||
trace!("Executing turn {} with {} intents", turn.turn_number, turn.intents.len());
|
||||
|
||||
// Use BorderCache for border data (avoids per-turn HashMap reconstruction)
|
||||
// BorderCache is updated by update_player_borders_system after territory changes
|
||||
let player_borders = resources.border_cache.as_map();
|
||||
|
||||
// Process bot AI to generate actions (must be done before execute_turn to avoid query conflicts)
|
||||
let bot_actions = crate::game::process_bot_actions(turn.turn_number, &territory_manager, &resources.terrain, &player_borders, rng.turn_number(), &mut player_queries.p2());
|
||||
|
||||
// Execute turn using standalone function
|
||||
crate::game::execute_turn(turn, turn.turn_number, bot_actions, &mut territory_manager, &resources.terrain, &mut active_attacks, &mut rng, &player_borders, &resources.player_entity_map, &mut player_queries.p0(), &mut commands, &resources.human_count, &mut launch_ship_writer);
|
||||
|
||||
if turn.turn_number == 0
|
||||
&& let Some(ref spawn_mgr) = spawn_manager
|
||||
{
|
||||
// Apply ALL spawns (both human player and bots) to game state on Turn(0)
|
||||
let all_spawns = spawn_mgr.get_all_spawns();
|
||||
tracing::debug!("Applying {} spawns to game state on Turn(0)", all_spawns.len());
|
||||
for spawn in all_spawns {
|
||||
crate::game::handle_spawn(spawn.nation, spawn.tile, &mut territory_manager, &resources.terrain, &mut active_attacks, &rng, &resources.player_entity_map, &mut player_queries.p0(), &mut commands);
|
||||
}
|
||||
}
|
||||
|
||||
let total_land_tiles = territory_manager.as_slice().iter().filter(|ownership| ownership.is_owned()).count() as u32;
|
||||
|
||||
// Collect changed tiles from territory manager for delta rendering
|
||||
// Use iter_changes() to preserve changes for update_player_borders_system
|
||||
let changed_tiles: Vec<u32> = territory_manager.iter_changes().map(|pos| territory_manager.pos_to_index(pos)).collect();
|
||||
|
||||
{
|
||||
let _guard = tracing::trace_span!("create_game_view_in_execute_turn").entered();
|
||||
|
||||
// Build players list from ECS components (source of truth)
|
||||
// Use p1() to access the read-only query after mutations are done
|
||||
let players_view: Vec<crate::game::view::PlayerView> = player_queries
|
||||
.p1()
|
||||
.iter()
|
||||
.map(|(nation_id, name, color, troops, territory_size)| {
|
||||
crate::game::view::PlayerView {
|
||||
id: *nation_id,
|
||||
color: color.0.to_rgba(),
|
||||
name: name.0.clone(),
|
||||
tile_count: territory_size.0,
|
||||
troops: troops.0 as u32,
|
||||
is_alive: true, // Query filters out Dead, so all remaining are alive
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
**game_view = GameView {
|
||||
size: territory_manager.size(),
|
||||
territories: Arc::from(territory_manager.as_slice()),
|
||||
turn_number: turn.turn_number,
|
||||
total_land_tiles,
|
||||
changed_tiles,
|
||||
players: players_view,
|
||||
ships: Vec::new(), // Will be populated by update_ship_views_system
|
||||
};
|
||||
}
|
||||
|
||||
trace!("GameView updated: turn {}", game_view.turn_number);
|
||||
|
||||
if turn.turn_number == 0 && spawn_phase.active {
|
||||
spawn_phase.active = false;
|
||||
|
||||
#[cfg(feature = "ui")]
|
||||
backend_messages.write(BackendMessage::SpawnPhaseEnded);
|
||||
|
||||
info!("Spawn phase ended after Turn(0) execution");
|
||||
|
||||
if let Some(ref handle) = server_handle {
|
||||
handle.resume();
|
||||
info!("Local turn server resumed - game started");
|
||||
}
|
||||
}
|
||||
|
||||
// Mark turn as processed to prevent re-execution
|
||||
current_turn.mark_processed();
|
||||
}
|
||||
|
||||
/// Update ship views in GameView
|
||||
/// Runs after execute_turn_gameplay_system to populate ship data
|
||||
pub fn update_ship_views_system(mut game_view: Option<ResMut<GameView>>, territory_manager: If<Res<crate::game::TerritoryManager>>, ships: Query<(&crate::game::ships::Ship, &ChildOf)>, player_query: Query<&crate::game::world::NationId>) {
|
||||
let Some(ref mut game_view) = game_view else {
|
||||
return;
|
||||
};
|
||||
|
||||
let ship_views = ships
|
||||
.iter()
|
||||
.map(|(ship, parent)| {
|
||||
let current_tile_pos = ship.get_current_tile();
|
||||
let current_tile_idx = territory_manager.pos_to_index(current_tile_pos);
|
||||
let target_tile_idx = territory_manager.pos_to_index(ship.target_tile);
|
||||
|
||||
let owner_id = player_query.get(parent.0).copied().unwrap_or(NationId::ZERO);
|
||||
|
||||
crate::game::view::ShipView { id: ship.id, owner_id, current_tile: current_tile_idx, target_tile: target_tile_idx, troops: ship.troops, path_progress: ship.current_path_index as u32, ticks_until_move: ship.ticks_per_tile.saturating_sub(ship.ticks_since_move), path: ship.path.iter().map(|&tile_pos| territory_manager.pos_to_index(tile_pos)).collect() }
|
||||
})
|
||||
.collect();
|
||||
|
||||
game_view.ships = ship_views;
|
||||
}
|
||||
|
||||
/// System to handle FrontendMessage events
|
||||
#[cfg(feature = "ui")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_frontend_messages_system(mut commands: Commands, mut frontend_messages: MessageReader<crate::ui::protocol::FrontendMessage>, territory_manager: Option<Res<crate::game::TerritoryManager>>, intent_receiver: Option<Res<IntentReceiver>>, mut attack_controls: Option<ResMut<AttackControls>>, mut spawn_phase: ResMut<SpawnPhase>, mut spawn_phase_init: ResMut<SpawnPhaseInitialized>, mut previous_spawn_state: ResMut<PreviousSpawnState>) {
|
||||
use crate::ui::protocol::FrontendMessage;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
for message in frontend_messages.read() {
|
||||
match message {
|
||||
FrontendMessage::StartGame => {
|
||||
let _guard = tracing::debug_span!("handle_start_game").entered();
|
||||
info!("Processing StartGame command");
|
||||
|
||||
if territory_manager.is_some() {
|
||||
error!("Game already running - ignoring StartGame");
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(ref intent_receiver) = intent_receiver else {
|
||||
error!("IntentReceiver not available - cannot start game");
|
||||
continue;
|
||||
};
|
||||
|
||||
let terrain_data = {
|
||||
let _guard = tracing::debug_span!("terrain_loading").entered();
|
||||
match crate::game::TerrainData::load_world_map() {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
error!("Failed to load World map: {}", e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let terrain_arc = Arc::new(terrain_data.clone());
|
||||
commands.insert_resource(terrain_data);
|
||||
|
||||
let size = terrain_arc.size();
|
||||
let width = size.x;
|
||||
let height = size.y;
|
||||
let tile_count = (width as usize) * (height as usize);
|
||||
let mut conquerable_tiles = Vec::with_capacity(tile_count);
|
||||
|
||||
{
|
||||
let _guard = tracing::trace_span!("conquerable_tiles_calculation", tile_count = tile_count).entered();
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
conquerable_tiles.push(terrain_arc.is_conquerable(glam::U16Vec2::new(x, y)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let params = crate::game::GameInitParams { map_width: width, map_height: height, conquerable_tiles, client_player_id: NationId::ZERO, intent_rx: intent_receiver.rx.clone(), terrain_data: terrain_arc };
|
||||
|
||||
crate::game::initialize_game_resources(&mut commands, params);
|
||||
info!("Game initialized successfully");
|
||||
}
|
||||
|
||||
FrontendMessage::QuitGame => {
|
||||
info!("Processing QuitGame command");
|
||||
if territory_manager.is_some() {
|
||||
// Remove all game-specific resources (refactored standalone resources)
|
||||
commands.remove_resource::<crate::game::TerritoryManager>();
|
||||
commands.remove_resource::<crate::game::ActiveAttacks>();
|
||||
commands.remove_resource::<crate::game::DeterministicRng>();
|
||||
commands.remove_resource::<crate::game::CoastalTiles>();
|
||||
commands.remove_resource::<crate::game::PlayerEntityMap>();
|
||||
commands.remove_resource::<crate::game::ClientPlayerId>();
|
||||
commands.remove_resource::<crate::game::HumanPlayerCount>();
|
||||
commands.remove_resource::<crate::game::LocalPlayerContext>();
|
||||
commands.remove_resource::<TurnReceiver>();
|
||||
commands.remove_resource::<crate::game::SpawnManager>();
|
||||
commands.remove_resource::<crate::game::SpawnTimeout>();
|
||||
commands.remove_resource::<GameView>();
|
||||
commands.remove_resource::<TerrainData>();
|
||||
commands.remove_resource::<TurnGenerator>();
|
||||
|
||||
// Reset permanent resources to default state
|
||||
spawn_phase.active = false;
|
||||
spawn_phase_init.emitted_initial = false;
|
||||
previous_spawn_state.spawns.clear();
|
||||
|
||||
// Note: LocalTurnServerHandle cleanup requires World access
|
||||
// It will be cleaned up automatically when the resource is dropped
|
||||
|
||||
info!("Game stopped and resources cleaned up");
|
||||
}
|
||||
}
|
||||
FrontendMessage::SetAttackRatio { ratio } => {
|
||||
if let Some(ref mut controls) = attack_controls {
|
||||
controls.attack_ratio = ratio.clamp(0.01, 1.0);
|
||||
debug!("Attack ratio set to {:.1}%", controls.attack_ratio * 100.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
315
crates/borders-core/src/telemetry/client.rs
Normal file
@@ -0,0 +1,315 @@
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use futures::lock::Mutex;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
use super::types::{BatchCaptureRequest, BatchEvent, TelemetryConfig, TelemetryEvent};
|
||||
use super::user_id::UserIdType;
|
||||
use crate::platform::spawn_task;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use super::user_id::get_or_create_user_id;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Build an HTTP client with appropriate DNS resolver for the platform.
|
||||
///
|
||||
/// On non-WASM targets, attempts to use Hickory DNS with DoH support.
|
||||
/// Falls back to default client if DoH initialization fails.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn build_http_client() -> reqwest::Client {
|
||||
match reqwest::Client::builder().dns_resolver(Arc::new(crate::dns::HickoryDnsResolver::new())).build() {
|
||||
Ok(client) => {
|
||||
debug!("HTTP client initialized with DoH resolver");
|
||||
client
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to build HTTP client with DoH: {}, using default", e);
|
||||
reqwest::Client::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn build_http_client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
/// A simple telemetry client that batches events and sends them to PostHog.
|
||||
///
|
||||
/// This client works on both native and WASM targets by using reqwest
|
||||
/// with appropriate feature flags.
|
||||
#[derive(Clone)]
|
||||
pub struct TelemetryClient {
|
||||
config: TelemetryConfig,
|
||||
client: reqwest::Client,
|
||||
/// Distinct ID for this client instance (anonymous user ID)
|
||||
distinct_id: String,
|
||||
/// Lightweight properties attached to every event
|
||||
default_properties: HashMap<String, serde_json::Value>,
|
||||
/// Event buffer for batching
|
||||
buffer: Arc<Mutex<Vec<TelemetryEvent>>>,
|
||||
/// Whether the flush task has been started
|
||||
flush_task_started: Arc<AtomicBool>,
|
||||
/// Track in-flight batch sends (native only)
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
in_flight_sends: Arc<tokio::sync::Mutex<Vec<tokio::task::JoinHandle<()>>>>,
|
||||
}
|
||||
|
||||
impl TelemetryClient {
|
||||
/// Create a new telemetry client with the given configuration.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn new(config: TelemetryConfig) -> Self {
|
||||
let (distinct_id, id_type) = get_or_create_user_id();
|
||||
debug!("Telemetry client initialized (user ID type: {})", id_type.as_str());
|
||||
|
||||
let default_properties = build_default_properties(id_type);
|
||||
|
||||
Self { config, client: build_http_client(), distinct_id, default_properties, buffer: Arc::new(Mutex::new(Vec::new())), flush_task_started: Arc::new(AtomicBool::new(false)), in_flight_sends: Arc::new(tokio::sync::Mutex::new(Vec::new())) }
|
||||
}
|
||||
|
||||
/// Create a new telemetry client with a pre-loaded user ID.
|
||||
///
|
||||
/// This is used on WASM where user ID loading is async.
|
||||
pub fn new_with_user_id(config: TelemetryConfig, distinct_id: String, id_type: UserIdType) -> Self {
|
||||
debug!("Telemetry client initialized (user ID type: {})", id_type.as_str());
|
||||
|
||||
let default_properties = build_default_properties(id_type);
|
||||
|
||||
Self {
|
||||
config,
|
||||
client: build_http_client(),
|
||||
distinct_id,
|
||||
default_properties,
|
||||
buffer: Arc::new(Mutex::new(Vec::new())),
|
||||
flush_task_started: Arc::new(AtomicBool::new(false)),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
in_flight_sends: Arc::new(tokio::sync::Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a background task that periodically flushes events.
|
||||
/// This ensures events are sent even if the batch size isn't reached.
|
||||
///
|
||||
/// Only starts once, subsequent calls are no-ops.
|
||||
fn ensure_flush_task_started(&self) {
|
||||
// Check if already started (fast path)
|
||||
if self.flush_task_started.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to start the task (only one thread will succeed)
|
||||
if self.flush_task_started.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire).is_err() {
|
||||
// Another thread beat us to it
|
||||
return;
|
||||
}
|
||||
|
||||
// We won the race, start the task
|
||||
let client = self.clone();
|
||||
let interval_secs = self.config.flush_interval_secs;
|
||||
|
||||
spawn_task(async move {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use std::time::Duration;
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(interval_secs));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
client.flush().await;
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
use gloo_timers::future::TimeoutFuture;
|
||||
loop {
|
||||
TimeoutFuture::new((interval_secs * 1000) as u32).await;
|
||||
client.flush().await;
|
||||
}
|
||||
}
|
||||
});
|
||||
debug!("Started periodic flush task (interval: {}s)", interval_secs);
|
||||
}
|
||||
|
||||
/// Track a telemetry event. Events are buffered and sent in batches.
|
||||
pub async fn track(&self, event: TelemetryEvent) {
|
||||
// Ensure the periodic flush task is running (lazy start)
|
||||
self.ensure_flush_task_started();
|
||||
|
||||
debug!("Buffering telemetry event: {}", event.event);
|
||||
|
||||
let mut buffer = self.buffer.lock().await;
|
||||
buffer.push(event);
|
||||
|
||||
// Check if we should flush based on batch size
|
||||
if buffer.len() >= self.config.batch_size {
|
||||
debug!("Batch size reached ({}), flushing events", buffer.len());
|
||||
let events_to_send = buffer.drain(..).collect::<Vec<_>>();
|
||||
drop(buffer); // Release lock before async operation
|
||||
|
||||
// Spawn a task to send in the background (non-blocking)
|
||||
let client = self.clone();
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let handle = tokio::spawn(async move {
|
||||
client.send_batch(events_to_send).await;
|
||||
});
|
||||
// Track the in-flight send and clean up completed tasks
|
||||
let mut in_flight = self.in_flight_sends.lock().await;
|
||||
in_flight.retain(|h| !h.is_finished());
|
||||
in_flight.push(handle);
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
spawn_task(async move {
|
||||
client.send_batch(events_to_send).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Manually flush all buffered events.
|
||||
///
|
||||
/// This method waits for all in-flight sends to complete, then sends any remaining buffered events.
|
||||
pub async fn flush(&self) {
|
||||
// First, wait for all in-flight background sends to complete
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let handles = {
|
||||
let mut in_flight = self.in_flight_sends.lock().await;
|
||||
in_flight.drain(..).collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
if !handles.is_empty() {
|
||||
debug!("Waiting for {} in-flight batch sends to complete", handles.len());
|
||||
for handle in handles {
|
||||
let _ = handle.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then flush any remaining buffered events
|
||||
let events_to_send = {
|
||||
let mut buffer = self.buffer.lock().await;
|
||||
|
||||
if buffer.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let events = buffer.drain(..).collect::<Vec<_>>();
|
||||
debug!("Flushing {} buffered events", events.len());
|
||||
events
|
||||
};
|
||||
|
||||
// Send synchronously (wait for completion)
|
||||
self.send_batch(events_to_send).await;
|
||||
}
|
||||
|
||||
/// Generate HMAC-SHA256 signature for request payload.
|
||||
///
|
||||
/// This prevents tampering and verifies request integrity.
|
||||
fn sign_payload(&self, payload: &[u8]) -> String {
|
||||
let mut mac = HmacSha256::new_from_slice(self.config.signing_key.as_bytes()).expect("HMAC can take key of any size");
|
||||
mac.update(payload);
|
||||
|
||||
// Convert to hex string
|
||||
let result = mac.finalize();
|
||||
let bytes = result.into_bytes();
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
/// Send a batch of events to PostHog.
|
||||
async fn send_batch(&self, events: Vec<TelemetryEvent>) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let batch_events: Vec<BatchEvent> = events
|
||||
.into_iter()
|
||||
.map(|mut event| {
|
||||
// Merge default properties with event properties
|
||||
// Event properties take precedence over defaults
|
||||
for (key, value) in &self.default_properties {
|
||||
event.properties.entry(key.clone()).or_insert(value.clone());
|
||||
}
|
||||
|
||||
BatchEvent { event: event.event, properties: event.properties, distinct_id: self.distinct_id.clone() }
|
||||
})
|
||||
.collect();
|
||||
|
||||
let payload = BatchCaptureRequest { api_key: self.config.api_key.clone(), batch: batch_events };
|
||||
|
||||
// Serialize payload to JSON bytes
|
||||
let payload_json = match serde_json::to_vec(&payload) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
error!("Failed to serialize telemetry payload: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Generate signature
|
||||
let signature = self.sign_payload(&payload_json);
|
||||
let url = format!("https://{}/batch", self.config.api_host);
|
||||
|
||||
// Send request with signature header
|
||||
match self.client.post(&url).header("X-Request-Signature", signature).header("Content-Type", "application/json").body(payload_json).send().await {
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
debug!("Telemetry batch sent successfully");
|
||||
} else {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
warn!("PostHog returned status {}: {}", status, body);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to send telemetry batch: {}", e);
|
||||
if let Some(source) = e.source() {
|
||||
error!("Caused by: {}", source);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the distinct ID for this client (useful for debugging)
|
||||
pub fn distinct_id(&self) -> &str {
|
||||
&self.distinct_id
|
||||
}
|
||||
|
||||
/// Get the user ID type for this client (useful for debugging)
|
||||
pub fn user_id_type(&self) -> Option<&str> {
|
||||
self.default_properties.get("user_id_type").and_then(|v| v.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the default properties that are attached to every event.
|
||||
fn build_default_properties(id_type: UserIdType) -> HashMap<String, serde_json::Value> {
|
||||
use crate::build_info;
|
||||
use serde_json::Value;
|
||||
|
||||
let mut props = HashMap::new();
|
||||
|
||||
let platform = if cfg!(target_arch = "wasm32") {
|
||||
"browser"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"desktop-windows"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"desktop-macos"
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"desktop-linux"
|
||||
} else {
|
||||
"desktop-unknown"
|
||||
};
|
||||
|
||||
props.insert("platform".to_string(), Value::String(platform.to_string()));
|
||||
props.insert("build_version".to_string(), Value::String(build_info::VERSION.to_string()));
|
||||
props.insert("build_commit".to_string(), Value::String(build_info::git_commit_short().to_string()));
|
||||
props.insert("user_id_type".to_string(), Value::String(id_type.as_str().to_string()));
|
||||
|
||||
props
|
||||
}
|
||||
122
crates/borders-core/src/telemetry/mod.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
//! Telemetry module for tracking analytics events.
|
||||
//!
|
||||
//! This module provides a simple, cross-platform telemetry client that works
|
||||
//! on both native (Tauri) and WASM targets. Events are batched and sent to
|
||||
//! PostHog via HTTP in a non-blocking manner.
|
||||
|
||||
mod client;
|
||||
|
||||
mod system_info;
|
||||
mod types;
|
||||
mod user_id;
|
||||
|
||||
pub use client::*;
|
||||
pub use system_info::*;
|
||||
pub use types::*;
|
||||
pub use user_id::*;
|
||||
|
||||
use once_cell::sync::OnceCell;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Global telemetry client instance.
|
||||
static TELEMETRY_CLIENT: OnceCell<TelemetryClient> = OnceCell::new();
|
||||
|
||||
/// Session start timestamp in milliseconds since epoch (for calculating session duration).
|
||||
static SESSION_START_MS: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Initialize the global telemetry client with the given configuration.
|
||||
///
|
||||
/// This should be called once at application startup.
|
||||
/// On WASM, this is async to load the user ID from IndexedDB.
|
||||
pub async fn init(config: TelemetryConfig) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let client = TelemetryClient::new(config);
|
||||
if TELEMETRY_CLIENT.set(client).is_err() {
|
||||
tracing::warn!("Telemetry client already initialized");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let (user_id, id_type) = get_or_create_user_id_async().await;
|
||||
let client = TelemetryClient::new_with_user_id(config, user_id, id_type);
|
||||
if TELEMETRY_CLIENT.set(client).is_err() {
|
||||
tracing::warn!("Telemetry client already initialized");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a reference to the global telemetry client.
|
||||
///
|
||||
/// Returns None if the client hasn't been initialized yet.
|
||||
pub fn client() -> Option<&'static TelemetryClient> {
|
||||
TELEMETRY_CLIENT.get()
|
||||
}
|
||||
|
||||
/// Track a telemetry event using the global client.
|
||||
///
|
||||
/// This is a convenience function that will do nothing if the client
|
||||
/// hasn't been initialized.
|
||||
pub async fn track(event: TelemetryEvent) {
|
||||
if let Some(client) = client() {
|
||||
client.track(event).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Track a session start event with detailed system information.
|
||||
///
|
||||
/// Should be called once after telemetry initialization.
|
||||
pub async fn track_session_start() {
|
||||
// Record session start time for duration calculation
|
||||
let now_ms = current_time_ms();
|
||||
SESSION_START_MS.store(now_ms, Ordering::Relaxed);
|
||||
|
||||
let system_info = SystemInfo::collect();
|
||||
let mut event = TelemetryEvent::new("session_start");
|
||||
|
||||
for (key, value) in system_info.to_properties() {
|
||||
event.properties.insert(key, value);
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let (browser_name, browser_version) = system_info::get_browser_info();
|
||||
event.properties.insert("browser_name".to_string(), serde_json::Value::String(browser_name));
|
||||
event.properties.insert("browser_version".to_string(), serde_json::Value::String(browser_version));
|
||||
}
|
||||
|
||||
track(event).await;
|
||||
}
|
||||
|
||||
/// Track a session end event with session duration.
|
||||
///
|
||||
/// Should be called when the application is closing.
|
||||
pub async fn track_session_end() {
|
||||
let start_ms = SESSION_START_MS.load(Ordering::Relaxed);
|
||||
if start_ms == 0 {
|
||||
tracing::warn!("Session end tracked but no session start found");
|
||||
return;
|
||||
}
|
||||
|
||||
let now_ms = current_time_ms();
|
||||
let duration_ms = now_ms.saturating_sub(start_ms);
|
||||
let duration_secs = duration_ms / 1000;
|
||||
|
||||
let event = TelemetryEvent::new("session_end").with_property("session_duration_ms", duration_ms).with_property("session_duration_secs", duration_secs);
|
||||
|
||||
track(event).await;
|
||||
}
|
||||
|
||||
/// Get current time in milliseconds since Unix epoch.
|
||||
fn current_time_ms() -> u64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
(js_sys::Date::now()) as u64
|
||||
}
|
||||
}
|
||||
148
crates/borders-core/src/telemetry/system_info.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
//! System information collection for analytics.
|
||||
//!
|
||||
//! Collects platform-specific system information for telemetry purposes.
|
||||
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Detailed system information collected once at session start.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SystemInfo {
|
||||
pub os_name: String,
|
||||
pub os_version: String,
|
||||
pub arch: String,
|
||||
pub cpu_brand: Option<String>,
|
||||
pub cpu_cores: Option<usize>,
|
||||
pub total_memory_mb: Option<u64>,
|
||||
}
|
||||
|
||||
impl SystemInfo {
|
||||
/// Collect system information for the current platform.
|
||||
pub fn collect() -> Self {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
Self::collect_native()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
Self::collect_wasm()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert system info to a HashMap for inclusion in telemetry events.
|
||||
pub fn to_properties(&self) -> HashMap<String, Value> {
|
||||
let mut props = HashMap::new();
|
||||
props.insert("os_name".to_string(), Value::String(self.os_name.clone()));
|
||||
props.insert("os_version".to_string(), Value::String(self.os_version.clone()));
|
||||
props.insert("arch".to_string(), Value::String(self.arch.clone()));
|
||||
|
||||
if let Some(brand) = &self.cpu_brand {
|
||||
props.insert("cpu_brand".to_string(), Value::String(brand.clone()));
|
||||
}
|
||||
if let Some(cores) = self.cpu_cores {
|
||||
props.insert("cpu_cores".to_string(), Value::Number(cores.into()));
|
||||
}
|
||||
if let Some(mem) = self.total_memory_mb {
|
||||
props.insert("total_memory_mb".to_string(), Value::Number(mem.into()));
|
||||
}
|
||||
|
||||
props
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn collect_native() -> Self {
|
||||
use sysinfo::System;
|
||||
|
||||
let mut sys = System::new_all();
|
||||
sys.refresh_all();
|
||||
|
||||
let os_name = System::name().unwrap_or_else(|| "Unknown".to_string());
|
||||
let os_version = System::os_version().unwrap_or_else(|| "Unknown".to_string());
|
||||
let arch = std::env::consts::ARCH.to_string();
|
||||
|
||||
let cpu_brand = sys.cpus().first().map(|cpu| cpu.brand().to_string());
|
||||
let cpu_cores = sys.cpus().len();
|
||||
let total_memory_mb = sys.total_memory() / 1024 / 1024;
|
||||
|
||||
Self { os_name, os_version, arch, cpu_brand, cpu_cores: Some(cpu_cores), total_memory_mb: Some(total_memory_mb) }
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn collect_wasm() -> Self {
|
||||
use wasm_bindgen::JsValue;
|
||||
|
||||
// In web workers, use the global scope instead of window
|
||||
let global = js_sys::global();
|
||||
let navigator = js_sys::Reflect::get(&global, &JsValue::from_str("navigator")).expect("navigator should be available");
|
||||
|
||||
// Call methods using Reflect to work with both Navigator and WorkerNavigator
|
||||
let user_agent = js_sys::Reflect::get(&navigator, &JsValue::from_str("userAgent")).ok().and_then(|v| v.as_string()).unwrap_or_default();
|
||||
let platform = js_sys::Reflect::get(&navigator, &JsValue::from_str("platform")).ok().and_then(|v| v.as_string()).unwrap_or_default();
|
||||
|
||||
let (os_name, os_version) = parse_user_agent(&user_agent);
|
||||
let arch = platform;
|
||||
|
||||
let cpu_cores = js_sys::Reflect::get(&navigator, &JsValue::from_str("hardwareConcurrency")).ok().and_then(|v| v.as_f64()).and_then(|f| if f > 0.0 { Some(f as usize) } else { None });
|
||||
|
||||
let device_memory = js_sys::Reflect::get(&navigator, &JsValue::from_str("deviceMemory")).ok().and_then(|v| v.as_f64()).map(|gb| (gb * 1024.0) as u64);
|
||||
|
||||
Self { os_name, os_version, arch, cpu_brand: None, cpu_cores, total_memory_mb: device_memory }
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse user agent string to extract OS name and version.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn parse_user_agent(ua: &str) -> (String, String) {
|
||||
if ua.contains("Windows NT 10.0") {
|
||||
("Windows".to_string(), "10/11".to_string())
|
||||
} else if ua.contains("Windows NT 6.3") {
|
||||
("Windows".to_string(), "8.1".to_string())
|
||||
} else if ua.contains("Windows NT 6.2") {
|
||||
("Windows".to_string(), "8".to_string())
|
||||
} else if ua.contains("Windows NT 6.1") {
|
||||
("Windows".to_string(), "7".to_string())
|
||||
} else if ua.contains("Mac OS X") {
|
||||
let version = ua.split("Mac OS X ").nth(1).and_then(|s| s.split(')').next()).unwrap_or("Unknown");
|
||||
("macOS".to_string(), version.replace('_', "."))
|
||||
} else if ua.contains("Android") {
|
||||
let version = ua.split("Android ").nth(1).and_then(|s| s.split(';').next()).unwrap_or("Unknown");
|
||||
("Android".to_string(), version.to_string())
|
||||
} else if ua.contains("Linux") {
|
||||
("Linux".to_string(), "Unknown".to_string())
|
||||
} else if ua.contains("iOS") || ua.contains("iPhone") || ua.contains("iPad") {
|
||||
let version = ua.split("OS ").nth(1).and_then(|s| s.split(' ').next()).unwrap_or("Unknown");
|
||||
("iOS".to_string(), version.replace('_', "."))
|
||||
} else {
|
||||
("Unknown".to_string(), "Unknown".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Get browser name and version from user agent.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn get_browser_info() -> (String, String) {
|
||||
use wasm_bindgen::JsValue;
|
||||
|
||||
// In web workers, use the global scope instead of window
|
||||
let global = js_sys::global();
|
||||
let navigator = js_sys::Reflect::get(&global, &JsValue::from_str("navigator")).expect("navigator should be available");
|
||||
|
||||
// Call methods using Reflect to work with both Navigator and WorkerNavigator
|
||||
let ua = js_sys::Reflect::get(&navigator, &JsValue::from_str("userAgent")).ok().and_then(|v| v.as_string()).unwrap_or_default();
|
||||
|
||||
if ua.contains("Edg/") {
|
||||
let version = ua.split("Edg/").nth(1).and_then(|s| s.split(' ').next()).unwrap_or("Unknown");
|
||||
("Edge".to_string(), version.to_string())
|
||||
} else if ua.contains("Chrome/") {
|
||||
let version = ua.split("Chrome/").nth(1).and_then(|s| s.split(' ').next()).unwrap_or("Unknown");
|
||||
("Chrome".to_string(), version.to_string())
|
||||
} else if ua.contains("Firefox/") {
|
||||
let version = ua.split("Firefox/").nth(1).and_then(|s| s.split(' ').next()).unwrap_or("Unknown");
|
||||
("Firefox".to_string(), version.to_string())
|
||||
} else if ua.contains("Safari/") && !ua.contains("Chrome") {
|
||||
let version = ua.split("Version/").nth(1).and_then(|s| s.split(' ').next()).unwrap_or("Unknown");
|
||||
("Safari".to_string(), version.to_string())
|
||||
} else {
|
||||
("Unknown".to_string(), "Unknown".to_string())
|
||||
}
|
||||
}
|
||||
77
crates/borders-core/src/telemetry/types.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Represents a telemetry event to be sent to PostHog.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TelemetryEvent {
|
||||
/// Unique event identifier (e.g., "app_started", "game_ended")
|
||||
pub event: String,
|
||||
|
||||
/// Properties associated with this event
|
||||
#[serde(skip_serializing_if = "HashMap::is_empty")]
|
||||
pub properties: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl TelemetryEvent {
|
||||
pub fn new(event: impl Into<String>) -> Self {
|
||||
Self { event: event.into(), properties: HashMap::new() }
|
||||
}
|
||||
|
||||
pub fn with_property(mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) -> Self {
|
||||
self.properties.insert(key.into(), value.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for the telemetry client.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TelemetryConfig {
|
||||
/// PostHog API key
|
||||
pub api_key: String,
|
||||
|
||||
/// API host (e.g., "observe.borders.xevion.dev")
|
||||
pub api_host: String,
|
||||
|
||||
/// Batch size - send events when this many are queued
|
||||
pub batch_size: usize,
|
||||
|
||||
/// Flush interval in seconds
|
||||
pub flush_interval_secs: u64,
|
||||
|
||||
/// HMAC signing key for request integrity verification
|
||||
pub signing_key: String,
|
||||
}
|
||||
|
||||
impl Default for TelemetryConfig {
|
||||
fn default() -> Self {
|
||||
// In development: send often with small batch size for fast feedback
|
||||
// In production: batch events but flush periodically to avoid losing data
|
||||
#[cfg(debug_assertions)]
|
||||
let (batch_size, flush_interval_secs) = (2, 5);
|
||||
#[cfg(not(debug_assertions))]
|
||||
let (batch_size, flush_interval_secs) = (10, 45);
|
||||
|
||||
Self {
|
||||
api_key: "phc_VmL3M9Sn9hBCpNRExnKLWOZqlYO5SXSUkAAwl3gXJek".to_string(),
|
||||
api_host: "observe.borders.xevion.dev".to_string(),
|
||||
batch_size,
|
||||
flush_interval_secs,
|
||||
// HMAC-SHA256 signing key for request integrity
|
||||
signing_key: "borders_telemetry_hmac_key_v1_2025".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// PostHog batch capture request payload
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct BatchCaptureRequest {
|
||||
pub api_key: String,
|
||||
pub batch: Vec<BatchEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct BatchEvent {
|
||||
pub event: String,
|
||||
pub properties: HashMap<String, serde_json::Value>,
|
||||
pub distinct_id: String,
|
||||
}
|
||||
268
crates/borders-core/src/telemetry/user_id.rs
Normal file
@@ -0,0 +1,268 @@
|
||||
use tracing::debug;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use tracing::warn;
|
||||
|
||||
/// Type of user ID that was generated or loaded.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UserIdType {
|
||||
/// ID was loaded from storage (existing user)
|
||||
Existing,
|
||||
/// ID was generated from hardware components
|
||||
Hardware,
|
||||
/// ID was newly generated random UUID
|
||||
New,
|
||||
}
|
||||
|
||||
impl UserIdType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
UserIdType::Existing => "existing",
|
||||
UserIdType::Hardware => "hardware",
|
||||
UserIdType::New => "new",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get or create a persistent user ID (sync version for native platforms).
|
||||
///
|
||||
/// This function attempts to identify the user through multiple strategies:
|
||||
/// 1. Stored UUID (persisted across runs, most reliable)
|
||||
/// 2. Hardware-based ID (hashed for privacy, then stored for future use)
|
||||
/// 3. Generate new UUID (if nothing exists)
|
||||
///
|
||||
/// Returns a tuple of (user_id, id_type).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn get_or_create_user_id() -> (String, UserIdType) {
|
||||
// Try to load stored ID first (most reliable)
|
||||
if let Some(stored_id) = load_stored_id() {
|
||||
debug!("Using stored user ID");
|
||||
return (stored_id, UserIdType::Existing);
|
||||
}
|
||||
|
||||
// Try hardware-based ID
|
||||
if let Some(hw_id) = get_hardware_id() {
|
||||
debug!("Generated hardware-based user ID");
|
||||
// Store it for future reliability
|
||||
if let Err(e) = store_user_id(&hw_id) {
|
||||
warn!("Failed to store hardware-based user ID: {}", e);
|
||||
}
|
||||
return (hw_id, UserIdType::Hardware);
|
||||
}
|
||||
|
||||
// Generate and store new ID
|
||||
let new_id = Uuid::new_v4().to_string();
|
||||
debug!("Generated new user ID");
|
||||
|
||||
if let Err(e) = store_user_id(&new_id) {
|
||||
warn!("Failed to store new user ID: {}", e);
|
||||
}
|
||||
|
||||
(new_id, UserIdType::New)
|
||||
}
|
||||
|
||||
/// Get or create a persistent user ID (async version for WASM).
|
||||
///
|
||||
/// This function attempts to identify the user through multiple strategies:
|
||||
/// 1. Stored UUID in localStorage (via main thread, persisted across runs)
|
||||
/// 2. Generate new UUID (if nothing exists)
|
||||
///
|
||||
/// Returns a tuple of (user_id, id_type).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn get_or_create_user_id_async() -> (String, UserIdType) {
|
||||
// Try to load from localStorage via main thread
|
||||
if let Some(stored_id) = load_from_localstorage().await {
|
||||
debug!("Loaded user ID from localStorage");
|
||||
return (stored_id, UserIdType::Existing);
|
||||
}
|
||||
|
||||
// Generate and store new ID
|
||||
let new_id = Uuid::new_v4().to_string();
|
||||
debug!("Generated new user ID");
|
||||
|
||||
// Try to store it (fire and forget)
|
||||
store_user_id(&new_id).ok();
|
||||
|
||||
(new_id, UserIdType::New)
|
||||
}
|
||||
|
||||
/// Attempt to get a hardware-based identifier.
|
||||
///
|
||||
/// Uses machineid-rs to build a stable ID from hardware components.
|
||||
/// The ID is hashed with SHA256 for privacy.
|
||||
///
|
||||
/// Only available on native platforms (not WASM).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn get_hardware_id() -> Option<String> {
|
||||
use machineid_rs::{Encryption, HWIDComponent, IdBuilder};
|
||||
|
||||
match IdBuilder::new(Encryption::SHA256).add_component(HWIDComponent::SystemID).add_component(HWIDComponent::CPUCores).build("iron-borders") {
|
||||
Ok(id) => {
|
||||
debug!("Successfully generated hardware ID");
|
||||
Some(id)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to generate hardware ID: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hardware IDs are not available on WASM.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[allow(dead_code)]
|
||||
fn get_hardware_id() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Load a previously stored user ID from platform-specific storage.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_stored_id() -> Option<String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
load_from_registry()
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
load_from_file()
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a user ID to platform-specific storage.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn store_user_id(id: &str) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
store_to_registry(id)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
store_to_file(id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn store_user_id(id: &str) -> Result<(), String> {
|
||||
use wasm_bindgen::JsValue;
|
||||
use web_sys::BroadcastChannel;
|
||||
|
||||
let channel = BroadcastChannel::new("user_id_storage").ok().ok_or("Failed to create channel")?;
|
||||
let msg = format!(r#"{{"action":"save","id":"{}"}}"#, id);
|
||||
channel.post_message(&JsValue::from_str(&msg)).ok().ok_or("Failed to post")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn load_from_registry() -> Option<String> {
|
||||
use winreg::RegKey;
|
||||
use winreg::enums::*;
|
||||
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
|
||||
match hkcu.open_subkey("Software\\Iron Borders\\ClientCache") {
|
||||
Ok(key) => match key.get_value::<String, _>("sid") {
|
||||
Ok(id) => {
|
||||
debug!("Loaded user ID from registry");
|
||||
Some(id)
|
||||
}
|
||||
Err(_) => None,
|
||||
},
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn store_to_registry(id: &str) -> Result<(), String> {
|
||||
use winreg::RegKey;
|
||||
use winreg::enums::*;
|
||||
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
|
||||
let (key, _) = hkcu.create_subkey("Software\\Iron Borders\\ClientCache").map_err(|e| format!("Failed to create registry key: {}", e))?;
|
||||
|
||||
key.set_value("sid", &id).map_err(|e| format!("Failed to set registry value: {}", e))?;
|
||||
|
||||
debug!("Stored user ID to registry");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_arch = "wasm32"), not(windows)))]
|
||||
fn load_from_file() -> Option<String> {
|
||||
use directories::ProjectDirs;
|
||||
use std::fs;
|
||||
|
||||
let proj_dirs = ProjectDirs::from("", "", "iron-borders")?;
|
||||
let data_dir = proj_dirs.data_dir();
|
||||
let file_path = data_dir.join("client.dat");
|
||||
|
||||
match fs::read_to_string(&file_path) {
|
||||
Ok(id) => {
|
||||
debug!("Loaded user ID from file: {:?}", file_path);
|
||||
Some(id.trim().to_string())
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_arch = "wasm32"), not(windows)))]
|
||||
fn store_to_file(id: &str) -> Result<(), String> {
|
||||
use directories::ProjectDirs;
|
||||
use std::fs;
|
||||
|
||||
let proj_dirs = ProjectDirs::from("", "", "iron-borders").ok_or("Failed to get project directories")?;
|
||||
|
||||
let data_dir = proj_dirs.data_dir();
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
fs::create_dir_all(data_dir).map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let file_path = data_dir.join("client.dat");
|
||||
|
||||
fs::write(&file_path, id).map_err(|e| format!("Failed to write user ID file: {}", e))?;
|
||||
|
||||
debug!("Stored user ID to file: {:?}", file_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
async fn load_from_localstorage() -> Option<String> {
|
||||
use gloo_timers::future::TimeoutFuture;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use web_sys::{BroadcastChannel, MessageEvent};
|
||||
|
||||
let channel = BroadcastChannel::new("user_id_storage").ok()?;
|
||||
let result = Arc::new(Mutex::new(None));
|
||||
let result_clone = result.clone();
|
||||
|
||||
let callback = Closure::wrap(Box::new(move |event: MessageEvent| {
|
||||
if let Some(data) = event.data().as_string()
|
||||
&& let Ok(parsed) = js_sys::JSON::parse(&data)
|
||||
&& let Some(obj) = parsed.dyn_ref::<js_sys::Object>()
|
||||
&& let Ok(action) = js_sys::Reflect::get(obj, &JsValue::from_str("action"))
|
||||
&& action.as_string().as_deref() == Some("load_response")
|
||||
&& let Ok(id_val) = js_sys::Reflect::get(obj, &JsValue::from_str("id"))
|
||||
&& let Some(id) = id_val.as_string()
|
||||
{
|
||||
*result_clone.lock().unwrap() = Some(id);
|
||||
}
|
||||
}) as Box<dyn FnMut(_)>);
|
||||
|
||||
channel.set_onmessage(Some(callback.as_ref().unchecked_ref()));
|
||||
|
||||
// Send load request
|
||||
let msg = r#"{"action":"load"}"#;
|
||||
channel.post_message(&JsValue::from_str(msg)).ok()?;
|
||||
|
||||
// Wait up to 100ms for response
|
||||
TimeoutFuture::new(100).await;
|
||||
|
||||
callback.forget();
|
||||
|
||||
result.lock().unwrap().clone()
|
||||
}
|
||||
58
crates/borders-core/src/time.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
/// Simple time tracking resource for ECS
|
||||
use bevy_ecs::prelude::Resource;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone, Resource)]
|
||||
pub struct Time {
|
||||
delta: Duration,
|
||||
elapsed: Duration,
|
||||
}
|
||||
|
||||
impl Time {
|
||||
pub fn new() -> Self {
|
||||
Self { delta: Duration::ZERO, elapsed: Duration::ZERO }
|
||||
}
|
||||
|
||||
pub fn update(&mut self, delta: Duration) {
|
||||
self.delta = delta;
|
||||
self.elapsed += delta;
|
||||
}
|
||||
|
||||
pub fn delta(&self) -> Duration {
|
||||
self.delta
|
||||
}
|
||||
|
||||
pub fn delta_secs(&self) -> f32 {
|
||||
self.delta.as_secs_f32()
|
||||
}
|
||||
|
||||
pub fn elapsed(&self) -> Duration {
|
||||
self.elapsed
|
||||
}
|
||||
|
||||
pub fn elapsed_secs(&self) -> f32 {
|
||||
self.elapsed.as_secs_f32()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Time {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed timestep time resource
|
||||
#[derive(Debug, Clone, Resource)]
|
||||
pub struct FixedTime {
|
||||
timestep: Duration,
|
||||
}
|
||||
|
||||
impl FixedTime {
|
||||
pub fn from_seconds(seconds: f64) -> Self {
|
||||
Self { timestep: Duration::from_secs_f64(seconds) }
|
||||
}
|
||||
|
||||
pub fn timestep(&self) -> Duration {
|
||||
self.timestep
|
||||
}
|
||||
}
|
||||
350
crates/borders-core/src/ui/input.rs
Normal file
@@ -0,0 +1,350 @@
|
||||
//! Platform-agnostic input handling for the game
|
||||
//!
|
||||
//! This module provides input types and utilities that work across
|
||||
//! all platforms (WASM, Tauri) without depending on Bevy's input system.
|
||||
|
||||
use bevy_ecs::prelude::Resource;
|
||||
|
||||
/// Mouse button identifier
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum MouseButton {
|
||||
Left = 0,
|
||||
Middle = 1,
|
||||
Right = 2,
|
||||
Back = 3,
|
||||
Forward = 4,
|
||||
}
|
||||
|
||||
impl MouseButton {
|
||||
pub fn from_u8(button: u8) -> Option<Self> {
|
||||
match button {
|
||||
0 => Some(Self::Left),
|
||||
1 => Some(Self::Middle),
|
||||
2 => Some(Self::Right),
|
||||
3 => Some(Self::Back),
|
||||
4 => Some(Self::Forward),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keyboard key codes (subset we actually use)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum KeyCode {
|
||||
KeyW,
|
||||
KeyA,
|
||||
KeyS,
|
||||
KeyD,
|
||||
KeyC,
|
||||
Digit1,
|
||||
Digit2,
|
||||
Space,
|
||||
Escape,
|
||||
}
|
||||
|
||||
impl KeyCode {
|
||||
pub fn from_string(key: &str) -> Option<Self> {
|
||||
match key {
|
||||
"KeyW" | "w" => Some(Self::KeyW),
|
||||
"KeyA" | "a" => Some(Self::KeyA),
|
||||
"KeyS" | "s" => Some(Self::KeyS),
|
||||
"KeyD" | "d" => Some(Self::KeyD),
|
||||
"KeyC" | "c" => Some(Self::KeyC),
|
||||
"Digit1" | "1" => Some(Self::Digit1),
|
||||
"Digit2" | "2" => Some(Self::Digit2),
|
||||
"Space" | " " => Some(Self::Space),
|
||||
"Escape" => Some(Self::Escape),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Button state (pressed or released)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ButtonState {
|
||||
Pressed,
|
||||
Released,
|
||||
}
|
||||
|
||||
/// World coordinates (in game units)
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct WorldPos {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
/// Screen coordinates (in pixels)
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ScreenPos {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
/// Tile coordinates on the map (u16, supporting maps up to 65535x65535)
|
||||
///
|
||||
/// This is now a type alias to glam::U16Vec2 for better vector operations.
|
||||
/// Use `tile_to_index()` and `tile_from_index()` for index conversions.
|
||||
pub type TileCoord = glam::U16Vec2;
|
||||
|
||||
/// Convert tile coordinates to linear tile index
|
||||
#[inline]
|
||||
pub fn tile_to_index(tile: TileCoord, map_width: u16) -> usize {
|
||||
(tile.y as usize) * (map_width as usize) + (tile.x as usize)
|
||||
}
|
||||
|
||||
/// Create tile coordinates from linear tile index
|
||||
#[inline]
|
||||
pub fn tile_from_index(index: usize, map_width: u16) -> TileCoord {
|
||||
let width_usize = map_width as usize;
|
||||
TileCoord::new((index % width_usize) as u16, (index / width_usize) as u16)
|
||||
}
|
||||
|
||||
/// Camera state for coordinate conversions
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CameraState {
|
||||
/// Camera position in world coordinates
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
/// Camera zoom level (1.0 = normal)
|
||||
pub zoom: f32,
|
||||
/// Viewport width in pixels
|
||||
pub viewport_width: f32,
|
||||
/// Viewport height in pixels
|
||||
pub viewport_height: f32,
|
||||
}
|
||||
|
||||
/// Input event from the frontend
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InputEvent {
|
||||
MouseButton { button: MouseButton, state: ButtonState, world_pos: Option<WorldPos>, tile: Option<TileCoord> },
|
||||
MouseMove { world_pos: WorldPos, screen_pos: ScreenPos, tile: Option<TileCoord> },
|
||||
MouseWheel { delta_x: f32, delta_y: f32 },
|
||||
KeyPress { key: KeyCode, state: ButtonState },
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Resource)]
|
||||
pub struct InputState {
|
||||
// Mouse state
|
||||
mouse_buttons: Vec<(MouseButton, ButtonState)>,
|
||||
cursor_world_pos: Option<WorldPos>,
|
||||
cursor_tile: Option<TileCoord>,
|
||||
mouse_wheel_delta: (f32, f32),
|
||||
|
||||
// Keyboard state
|
||||
keys_pressed: Vec<KeyCode>,
|
||||
keys_just_pressed: Vec<KeyCode>,
|
||||
keys_just_released: Vec<KeyCode>,
|
||||
|
||||
// Track if camera was interacted with (for click filtering)
|
||||
camera_interaction: bool,
|
||||
}
|
||||
|
||||
impl InputState {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Clear per-frame data (call at start of frame)
|
||||
pub fn clear_frame_data(&mut self) {
|
||||
self.mouse_buttons.clear();
|
||||
self.keys_just_pressed.clear();
|
||||
self.keys_just_released.clear();
|
||||
self.mouse_wheel_delta = (0.0, 0.0);
|
||||
self.camera_interaction = false;
|
||||
}
|
||||
|
||||
/// Process an input event
|
||||
pub fn handle_event(&mut self, event: InputEvent) {
|
||||
match event {
|
||||
InputEvent::MouseButton { button, state, world_pos, tile } => {
|
||||
self.mouse_buttons.push((button, state));
|
||||
if world_pos.is_some() {
|
||||
self.cursor_world_pos = world_pos;
|
||||
}
|
||||
if tile.is_some() {
|
||||
self.cursor_tile = tile;
|
||||
}
|
||||
}
|
||||
InputEvent::MouseMove { world_pos, tile, .. } => {
|
||||
self.cursor_world_pos = Some(world_pos);
|
||||
self.cursor_tile = tile;
|
||||
}
|
||||
InputEvent::MouseWheel { delta_x, delta_y } => {
|
||||
self.mouse_wheel_delta.0 += delta_x;
|
||||
self.mouse_wheel_delta.1 += delta_y;
|
||||
// Mouse wheel = camera interaction
|
||||
if delta_x.abs() > 0.0 || delta_y.abs() > 0.0 {
|
||||
self.camera_interaction = true;
|
||||
}
|
||||
}
|
||||
InputEvent::KeyPress { key, state } => match state {
|
||||
ButtonState::Pressed => {
|
||||
if !self.keys_pressed.contains(&key) {
|
||||
self.keys_pressed.push(key);
|
||||
self.keys_just_pressed.push(key);
|
||||
}
|
||||
}
|
||||
ButtonState::Released => {
|
||||
self.keys_pressed.retain(|&k| k != key);
|
||||
self.keys_just_released.push(key);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a mouse button was just pressed this frame
|
||||
pub fn mouse_just_pressed(&self, button: MouseButton) -> bool {
|
||||
self.mouse_buttons.iter().any(|&(b, s)| b == button && s == ButtonState::Pressed)
|
||||
}
|
||||
|
||||
/// Check if a mouse button was just released this frame
|
||||
pub fn mouse_just_released(&self, button: MouseButton) -> bool {
|
||||
self.mouse_buttons.iter().any(|&(b, s)| b == button && s == ButtonState::Released)
|
||||
}
|
||||
|
||||
/// Check if a key is currently pressed
|
||||
pub fn key_pressed(&self, key: KeyCode) -> bool {
|
||||
self.keys_pressed.contains(&key)
|
||||
}
|
||||
|
||||
/// Check if a key was just pressed this frame
|
||||
pub fn key_just_pressed(&self, key: KeyCode) -> bool {
|
||||
self.keys_just_pressed.contains(&key)
|
||||
}
|
||||
|
||||
/// Check if a key was just released this frame
|
||||
pub fn key_just_released(&self, key: KeyCode) -> bool {
|
||||
self.keys_just_released.contains(&key)
|
||||
}
|
||||
|
||||
/// Get current cursor position in world coordinates
|
||||
pub fn cursor_world_pos(&self) -> Option<WorldPos> {
|
||||
self.cursor_world_pos
|
||||
}
|
||||
|
||||
/// Get current tile under cursor
|
||||
pub fn cursor_tile(&self) -> Option<TileCoord> {
|
||||
self.cursor_tile
|
||||
}
|
||||
|
||||
/// Get mouse wheel delta for this frame
|
||||
pub fn mouse_wheel_delta(&self) -> (f32, f32) {
|
||||
self.mouse_wheel_delta
|
||||
}
|
||||
|
||||
/// Check if camera was interacted with (for filtering clicks)
|
||||
pub fn had_camera_interaction(&self) -> bool {
|
||||
self.camera_interaction
|
||||
}
|
||||
|
||||
/// Mark that camera was interacted with
|
||||
pub fn set_camera_interaction(&mut self) {
|
||||
self.camera_interaction = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Coordinate conversion utilities
|
||||
pub mod coords {
|
||||
use super::*;
|
||||
|
||||
/// Convert screen position to world position
|
||||
pub fn screen_to_world(screen: ScreenPos, camera: &CameraState) -> WorldPos {
|
||||
// Adjust for camera position and zoom
|
||||
let world_x = (screen.x - camera.viewport_width / 2.0) / camera.zoom + camera.x;
|
||||
let world_y = (screen.y - camera.viewport_height / 2.0) / camera.zoom + camera.y;
|
||||
WorldPos { x: world_x, y: world_y }
|
||||
}
|
||||
|
||||
/// Convert world position to screen position
|
||||
pub fn world_to_screen(world: WorldPos, camera: &CameraState) -> ScreenPos {
|
||||
let screen_x = (world.x - camera.x) * camera.zoom + camera.viewport_width / 2.0;
|
||||
let screen_y = (world.y - camera.y) * camera.zoom + camera.viewport_height / 2.0;
|
||||
ScreenPos { x: screen_x, y: screen_y }
|
||||
}
|
||||
|
||||
/// Convert world position to tile coordinates
|
||||
pub fn world_to_tile(world: WorldPos, map_width: u16, map_height: u16, pixel_scale: f32) -> Option<TileCoord> {
|
||||
// Adjust for centered map
|
||||
let half_width = (map_width as f32 * pixel_scale) / 2.0;
|
||||
let half_height = (map_height as f32 * pixel_scale) / 2.0;
|
||||
|
||||
let adjusted_x = world.x + half_width;
|
||||
let adjusted_y = world.y + half_height;
|
||||
|
||||
let tile_x = (adjusted_x / pixel_scale) as i32;
|
||||
let tile_y = (adjusted_y / pixel_scale) as i32;
|
||||
|
||||
if tile_x >= 0 && tile_x < map_width as i32 && tile_y >= 0 && tile_y < map_height as i32 { Some(TileCoord::new(tile_x as u16, tile_y as u16)) } else { None }
|
||||
}
|
||||
|
||||
/// Convert tile coordinates to world position (center of tile)
|
||||
pub fn tile_to_world(tile: TileCoord, map_width: u16, map_height: u16, pixel_scale: f32) -> WorldPos {
|
||||
let half_width = (map_width as f32 * pixel_scale) / 2.0;
|
||||
let half_height = (map_height as f32 * pixel_scale) / 2.0;
|
||||
|
||||
WorldPos { x: (tile.x as f32 + 0.5) * pixel_scale - half_width, y: (tile.y as f32 + 0.5) * pixel_scale - half_height }
|
||||
}
|
||||
|
||||
/// Convert tile index to world position
|
||||
pub fn tile_index_to_world(index: usize, map_width: u16, map_height: u16, pixel_scale: f32) -> WorldPos {
|
||||
let tile = crate::ui::input::tile_from_index(index, map_width);
|
||||
tile_to_world(tile, map_width, map_height, pixel_scale)
|
||||
}
|
||||
|
||||
/// Convert screen position directly to tile (combines screen_to_world and world_to_tile)
|
||||
pub fn screen_to_tile(screen: ScreenPos, camera: &CameraState, map_width: u16, map_height: u16, pixel_scale: f32) -> Option<TileCoord> {
|
||||
let world = screen_to_world(screen, camera);
|
||||
world_to_tile(world, map_width, map_height, pixel_scale)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_tile_coord_conversion() {
|
||||
let tile = TileCoord::new(5, 3);
|
||||
let index = tile_to_index(tile, 10);
|
||||
assert_eq!(index, 35); // 3 * 10 + 5
|
||||
|
||||
let tile2 = tile_from_index(35, 10);
|
||||
assert_eq!(tile2.x, 5);
|
||||
assert_eq!(tile2.y, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_input_state() {
|
||||
let mut state = InputState::new();
|
||||
|
||||
// Test key press
|
||||
state.handle_event(InputEvent::KeyPress { key: KeyCode::KeyC, state: ButtonState::Pressed });
|
||||
assert!(state.key_just_pressed(KeyCode::KeyC));
|
||||
assert!(state.key_pressed(KeyCode::KeyC));
|
||||
|
||||
// Clear frame data
|
||||
state.clear_frame_data();
|
||||
assert!(!state.key_just_pressed(KeyCode::KeyC));
|
||||
assert!(state.key_pressed(KeyCode::KeyC)); // Still pressed
|
||||
|
||||
// Release key
|
||||
state.handle_event(InputEvent::KeyPress { key: KeyCode::KeyC, state: ButtonState::Released });
|
||||
assert!(state.key_just_released(KeyCode::KeyC));
|
||||
assert!(!state.key_pressed(KeyCode::KeyC));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_coordinate_conversion() {
|
||||
let camera = CameraState { x: 100.0, y: 100.0, zoom: 2.0, viewport_width: 800.0, viewport_height: 600.0 };
|
||||
|
||||
let screen = ScreenPos { x: 400.0, y: 300.0 };
|
||||
let world = coords::screen_to_world(screen, &camera);
|
||||
assert_eq!(world.x, 100.0); // Center of screen = camera position
|
||||
assert_eq!(world.y, 100.0);
|
||||
|
||||
// Test round trip
|
||||
let screen2 = coords::world_to_screen(world, &camera);
|
||||
assert!((screen2.x - screen.x).abs() < 0.001);
|
||||
assert!((screen2.y - screen.y).abs() < 0.001);
|
||||
}
|
||||
}
|
||||
239
crates/borders-core/src/ui/leaderboard.rs
Normal file
@@ -0,0 +1,239 @@
|
||||
//! Shared leaderboard data structures and utilities
|
||||
//!
|
||||
//! This module contains types and systems for managing leaderboard data
|
||||
//! that are shared between desktop and WASM builds.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::game::view::GameView;
|
||||
use crate::game::{ClientPlayerId, PlayerColor, PlayerName, world::NationId};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::time::Instant;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use web_time::Instant;
|
||||
|
||||
// Re-export UI types from protocol for convenience
|
||||
pub use crate::ui::protocol::{AttackEntry, AttacksUpdatePayload, BackendMessage, LeaderboardEntry, LeaderboardSnapshot};
|
||||
|
||||
/// Convert RGBA color to hex string (without alpha)
|
||||
pub fn rgba_to_hex(color: [f32; 4]) -> String {
|
||||
let r = (color[0] * 255.0) as u8;
|
||||
let g = (color[1] * 255.0) as u8;
|
||||
let b = (color[2] * 255.0) as u8;
|
||||
format!("{:02X}{:02X}{:02X}", r, g, b)
|
||||
}
|
||||
|
||||
/// Resolve a player's display name with fallbacks for missing or empty names
|
||||
fn resolve_player_name(player_id: NationId, client_player_id: NationId, player_data: Option<(&PlayerName, &PlayerColor)>) -> String {
|
||||
player_data.map(|(name, _)| if name.0.is_empty() { if player_id == client_player_id { "Player".to_string() } else { format!("Nation {}", player_id) } } else { name.0.clone() }).unwrap_or_else(|| format!("Nation {}", player_id))
|
||||
}
|
||||
|
||||
/// Resource to track last emitted leaderboard state for deduplication
|
||||
#[derive(Resource, Default, Debug)]
|
||||
pub struct LastLeaderboardDigest {
|
||||
pub entries: Vec<(NationId, String, u32, u32)>, // (id, name, tile_count, troops)
|
||||
pub turn: u64,
|
||||
}
|
||||
|
||||
/// Resource to track last emitted attacks state for deduplication
|
||||
#[derive(Resource, Default, Debug)]
|
||||
pub struct LastAttacksDigest {
|
||||
pub entries: Vec<(NationId, Option<NationId>, u32, u64, bool)>, // (attacker_id, target_id, troops, id, is_outgoing)
|
||||
pub turn: u64,
|
||||
pub count: usize, // Track number of attacks to always detect add/remove
|
||||
}
|
||||
|
||||
/// Resource to track display order update cycles (updates every 3rd tick)
|
||||
#[derive(Resource, Default, Debug)]
|
||||
pub struct DisplayOrderUpdateCounter {
|
||||
pub tick: u32,
|
||||
}
|
||||
|
||||
/// Resource to track last calculated display order for each nation
|
||||
#[derive(Resource, Default, Debug)]
|
||||
pub struct LastDisplayOrder {
|
||||
pub order_map: HashMap<NationId, usize>,
|
||||
}
|
||||
|
||||
/// Resource to throttle leaderboard snapshot emissions
|
||||
#[derive(Resource, Debug)]
|
||||
pub struct LeaderboardThrottle {
|
||||
last_emission: Option<Instant>,
|
||||
throttle_duration: core::time::Duration,
|
||||
}
|
||||
|
||||
impl Default for LeaderboardThrottle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
last_emission: None,
|
||||
throttle_duration: core::time::Duration::from_millis(420), // 420ms (3x faster, display order updates every 3rd tick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a complete leaderboard snapshot from current game state
|
||||
/// Returns None if nothing has changed since last_digest
|
||||
pub fn build_leaderboard_snapshot(game_view: &GameView, client_player_id: NationId, players_by_id: &HashMap<NationId, (&PlayerName, &PlayerColor)>, last_digest: &mut LastLeaderboardDigest, display_order_map: &HashMap<NationId, usize>) -> Option<LeaderboardSnapshot> {
|
||||
let total_land_tiles = game_view.total_land_tiles;
|
||||
|
||||
// Build current digest for comparison (includes names now), filter out eliminated players
|
||||
let current_entries: Vec<(NationId, String, u32, u32)> = game_view
|
||||
.players
|
||||
.iter()
|
||||
.filter(|p| p.tile_count > 0) // Exclude eliminated players
|
||||
.map(|p| {
|
||||
let player_data = players_by_id.get(&p.id).copied();
|
||||
let name = resolve_player_name(p.id, client_player_id, player_data);
|
||||
(p.id, name, p.tile_count, p.troops)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Check if anything has changed (stats OR names)
|
||||
if current_entries == last_digest.entries && game_view.turn_number == last_digest.turn {
|
||||
return None; // No changes
|
||||
}
|
||||
|
||||
// Update digest
|
||||
last_digest.entries = current_entries;
|
||||
last_digest.turn = game_view.turn_number;
|
||||
|
||||
// Build complete leaderboard entries (names + colors + stats), filter out eliminated players
|
||||
let mut entries: Vec<LeaderboardEntry> = game_view
|
||||
.players
|
||||
.iter()
|
||||
.filter(|p| p.tile_count > 0) // Exclude eliminated players
|
||||
.map(|player| {
|
||||
let player_data = players_by_id.get(&player.id).copied();
|
||||
|
||||
let name = resolve_player_name(player.id, client_player_id, player_data);
|
||||
|
||||
let color = player_data.map(|(_, color)| rgba_to_hex(color.0.to_rgba())).unwrap_or_else(|| "808080".to_string()); // Gray fallback
|
||||
|
||||
let territory_percent = if total_land_tiles > 0 { player.tile_count as f32 / total_land_tiles as f32 } else { 0.0 };
|
||||
|
||||
LeaderboardEntry {
|
||||
id: player.id,
|
||||
name,
|
||||
color,
|
||||
tile_count: player.tile_count,
|
||||
troops: player.troops,
|
||||
territory_percent,
|
||||
rank: 0, // Assigned after sorting
|
||||
display_order: 0, // Assigned after sorting
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by tile count descending
|
||||
entries.sort_by(|a, b| b.tile_count.cmp(&a.tile_count));
|
||||
|
||||
// Assign rank and display_order after sorting
|
||||
for (idx, entry) in entries.iter_mut().enumerate() {
|
||||
entry.rank = idx + 1; // 1-indexed rank
|
||||
|
||||
// Use display_order from map, or fallback to current rank position
|
||||
// TODO: Handle mid-game joins by initializing display_order for new players
|
||||
entry.display_order = display_order_map.get(&entry.id).copied().unwrap_or(idx);
|
||||
}
|
||||
|
||||
Some(LeaderboardSnapshot { turn: game_view.turn_number, total_land_tiles, entries, client_player_id })
|
||||
}
|
||||
|
||||
/// Bevy system that emits leaderboard snapshot events
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn emit_leaderboard_snapshot_system(game_view: If<Res<GameView>>, client_player_id: Option<Res<ClientPlayerId>>, players: Query<(&NationId, &PlayerName, &PlayerColor)>, mut last_digest: If<ResMut<LastLeaderboardDigest>>, mut throttle: If<ResMut<LeaderboardThrottle>>, mut counter: If<ResMut<DisplayOrderUpdateCounter>>, mut last_display_order: If<ResMut<LastDisplayOrder>>, mut backend_messages: MessageWriter<BackendMessage>) {
|
||||
let _guard = tracing::debug_span!("emit_leaderboard_snapshot").entered();
|
||||
|
||||
let Some(ref client_player_id) = client_player_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Check if enough time has passed since last emission
|
||||
let now = Instant::now();
|
||||
let should_emit = throttle.last_emission.map(|last| now.duration_since(last) >= throttle.throttle_duration).unwrap_or(true); // Emit on first call
|
||||
|
||||
if !should_emit {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build player lookup map from ECS components
|
||||
let players_by_id: HashMap<NationId, (&PlayerName, &PlayerColor)> = players.iter().map(|(nation_id, name, color)| (*nation_id, (name, color))).collect();
|
||||
|
||||
// Increment tick counter (wraps on overflow)
|
||||
counter.tick = counter.tick.wrapping_add(1);
|
||||
|
||||
// Every 3rd tick, recalculate display order from current rankings
|
||||
if counter.tick.is_multiple_of(3) {
|
||||
// Build temporary sorted list to determine new display order
|
||||
let mut sorted_players: Vec<_> = game_view
|
||||
.players
|
||||
.iter()
|
||||
.filter(|p| p.tile_count > 0) // Exclude eliminated players
|
||||
.collect();
|
||||
sorted_players.sort_by(|a, b| b.tile_count.cmp(&a.tile_count));
|
||||
|
||||
// Update display order map with current rankings
|
||||
last_display_order.order_map.clear();
|
||||
for (idx, player) in sorted_players.iter().enumerate() {
|
||||
last_display_order.order_map.insert(player.id, idx);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(snapshot) = build_leaderboard_snapshot(&game_view, client_player_id.0, &players_by_id, &mut last_digest, &last_display_order.order_map) {
|
||||
backend_messages.write(BackendMessage::LeaderboardSnapshot(snapshot));
|
||||
throttle.last_emission = Some(now);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an attacks update payload from current game state
|
||||
/// Always returns the current state (digest is used to prevent duplicate emissions)
|
||||
pub fn build_attacks_update(active_attacks: &crate::game::ActiveAttacks, turn_number: u64, client_player_id: NationId, last_digest: &mut LastAttacksDigest) -> Option<AttacksUpdatePayload> {
|
||||
// Get attacks for the client player
|
||||
let raw_attacks = active_attacks.get_attacks_for_player(client_player_id);
|
||||
|
||||
// Build current digest for comparison
|
||||
let current_entries = raw_attacks.iter().map(|&(attacker_id, target_id, troops, id, is_outgoing)| (attacker_id, target_id, troops as u32, id, is_outgoing)).collect();
|
||||
|
||||
let current_count = raw_attacks.len();
|
||||
|
||||
// Always send update if attack count changed (add/remove)
|
||||
let count_changed = current_count != last_digest.count;
|
||||
|
||||
// Check if digest changed (troop counts, etc.)
|
||||
let digest_changed = current_entries != last_digest.entries;
|
||||
|
||||
if !count_changed && !digest_changed {
|
||||
return None; // No changes at all
|
||||
}
|
||||
|
||||
// Update digest
|
||||
last_digest.entries = current_entries;
|
||||
last_digest.turn = turn_number;
|
||||
last_digest.count = current_count;
|
||||
|
||||
// Build attack entries
|
||||
let entries: Vec<AttackEntry> = raw_attacks.into_iter().map(|(attacker_id, target_id, troops, id, is_outgoing)| AttackEntry { id, attacker_id, target_id, troops: troops as u32, is_outgoing }).collect();
|
||||
|
||||
Some(AttacksUpdatePayload { turn: turn_number, entries })
|
||||
}
|
||||
|
||||
/// Bevy system that emits attacks update events
|
||||
pub fn emit_attacks_update_system(active_attacks: Option<Res<crate::game::ActiveAttacks>>, game_view: If<Res<GameView>>, client_player_id: Option<Res<ClientPlayerId>>, mut last_digest: If<ResMut<LastAttacksDigest>>, mut backend_messages: MessageWriter<BackendMessage>) {
|
||||
let _guard = tracing::debug_span!("emit_attacks_update").entered();
|
||||
|
||||
let Some(ref active_attacks) = active_attacks else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(ref client_player_id) = client_player_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(payload) = build_attacks_update(active_attacks, game_view.turn_number, client_player_id.0, &mut last_digest) {
|
||||
backend_messages.write(BackendMessage::AttacksUpdate(payload));
|
||||
}
|
||||
}
|
||||
106
crates/borders-core/src/ui/mod.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
//! UI/Frontend module for rendering and user interaction
|
||||
//!
|
||||
//! This module contains all frontend-related concerns including:
|
||||
//! - Protocol definitions for frontend-backend communication
|
||||
//! - Input handling
|
||||
//! - Leaderboard management
|
||||
//! - Platform transport abstraction
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::game::NationId;
|
||||
use crate::game::view::GameView;
|
||||
|
||||
pub mod input;
|
||||
pub mod leaderboard;
|
||||
pub mod plugin;
|
||||
pub mod protocol;
|
||||
pub mod transport;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use input::*;
|
||||
pub use leaderboard::*;
|
||||
pub use plugin::*;
|
||||
pub use protocol::*;
|
||||
pub use transport::*;
|
||||
|
||||
/// Resource to track currently highlighted nation for visual feedback
|
||||
#[derive(Resource, Default, Debug)]
|
||||
pub struct NationHighlightState {
|
||||
pub highlighted_nation: Option<NationId>,
|
||||
}
|
||||
|
||||
/// System that tracks hovered nation and emits highlight events
|
||||
pub fn emit_nation_highlight_system(input_state: NonSend<Arc<Mutex<InputState>>>, game_view: If<Res<GameView>>, mut highlight_state: If<ResMut<NationHighlightState>>, mut backend_messages: MessageWriter<BackendMessage>) {
|
||||
let Ok(input) = input_state.lock() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let new_highlighted = if let Some(tile_coord) = input.cursor_tile() {
|
||||
let tile_index = tile_to_index(tile_coord, game_view.width());
|
||||
let ownership = game_view.get_ownership(tile_index as u32);
|
||||
ownership.nation_id()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Only emit if highlight changed
|
||||
if new_highlighted != highlight_state.highlighted_nation {
|
||||
highlight_state.highlighted_nation = new_highlighted;
|
||||
backend_messages.write(BackendMessage::HighlightNation { nation_id: new_highlighted });
|
||||
}
|
||||
}
|
||||
|
||||
/// Resource to track previous ship states for delta updates
|
||||
#[derive(Resource, Default)]
|
||||
pub struct ShipStateTracker {
|
||||
/// Map of ship ID to current_path_index
|
||||
ship_indices: HashMap<u32, u32>,
|
||||
}
|
||||
|
||||
/// System that emits ship update variants to the frontend (delta-based)
|
||||
/// - Create: sent when ship first appears
|
||||
/// - Move: sent only when current_path_index changes
|
||||
/// - Destroy: sent when ship disappears
|
||||
pub fn emit_ships_update_system(game_view: If<Res<GameView>>, mut ship_tracker: ResMut<ShipStateTracker>, mut backend_messages: MessageWriter<BackendMessage>) {
|
||||
let current_ship_ids: HashSet<u32> = game_view.ships.iter().map(|s| s.id).collect();
|
||||
|
||||
let mut updates = Vec::new();
|
||||
|
||||
// Detect destroyed ships
|
||||
for &ship_id in ship_tracker.ship_indices.keys() {
|
||||
if !current_ship_ids.contains(&ship_id) {
|
||||
updates.push(ShipUpdateVariant::Destroy { id: ship_id });
|
||||
}
|
||||
}
|
||||
|
||||
// Detect new ships and moved ships
|
||||
for ship in &game_view.ships {
|
||||
match ship_tracker.ship_indices.get(&ship.id) {
|
||||
None => {
|
||||
// New ship - send Create
|
||||
updates.push(ShipUpdateVariant::Create { id: ship.id, owner_id: ship.owner_id, path: ship.path.clone(), troops: ship.troops });
|
||||
ship_tracker.ship_indices.insert(ship.id, ship.path_progress);
|
||||
}
|
||||
Some(&prev_index) if prev_index != ship.path_progress => {
|
||||
// Ship moved to next tile - send Move
|
||||
updates.push(ShipUpdateVariant::Move { id: ship.id, current_path_index: ship.path_progress });
|
||||
ship_tracker.ship_indices.insert(ship.id, ship.path_progress);
|
||||
}
|
||||
_ => {
|
||||
// No change, do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up destroyed ships from tracker
|
||||
ship_tracker.ship_indices.retain(|id, _| current_ship_ids.contains(id));
|
||||
|
||||
// Only send if there are updates
|
||||
if !updates.is_empty() {
|
||||
backend_messages.write(BackendMessage::ShipsUpdate(ShipsUpdatePayload { turn: game_view.turn_number, updates }));
|
||||
}
|
||||
}
|
||||
50
crates/borders-core/src/ui/plugin.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
//! Frontend plugin for UI/rendering integration
|
||||
//!
|
||||
//! This module provides the FrontendPlugin which handles all frontend communication
|
||||
//! including rendering, input, and UI updates.
|
||||
|
||||
use crate::app::{App, Plugin, Update};
|
||||
use crate::game::view::GameView;
|
||||
use crate::ui::protocol::{BackendMessage, FrontendMessage};
|
||||
use crate::ui::transport::{FrontendTransport, RenderBridge, emit_backend_messages_system, ingest_frontend_messages_system, send_initial_render_data, stream_territory_deltas};
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
/// Plugin to add frontend communication and UI systems to Bevy
|
||||
pub struct FrontendPlugin<T: FrontendTransport> {
|
||||
transport: T,
|
||||
}
|
||||
|
||||
impl<T: FrontendTransport> FrontendPlugin<T> {
|
||||
pub fn new(transport: T) -> Self {
|
||||
Self { transport }
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: FrontendTransport> Plugin for FrontendPlugin<T> {
|
||||
fn build(&self, app: &mut App) {
|
||||
let _guard = tracing::trace_span!("frontend_plugin_build").entered();
|
||||
|
||||
// Register message event types
|
||||
app.add_message::<BackendMessage>();
|
||||
app.add_message::<FrontendMessage>();
|
||||
|
||||
// Insert the bridge resource
|
||||
app.insert_resource(RenderBridge::new(self.transport.clone()));
|
||||
|
||||
// Add render systems
|
||||
app.add_systems(Update, (send_initial_render_data::<T>, stream_territory_deltas::<T>).chain());
|
||||
|
||||
// Add communication systems
|
||||
app.add_systems(Update, (emit_backend_messages_system::<T>, ingest_frontend_messages_system::<T>, reset_bridge_on_quit_system::<T>));
|
||||
}
|
||||
}
|
||||
|
||||
/// System to reset the render bridge when a game is quit
|
||||
/// This ensures fresh initialization data is sent when starting a new game
|
||||
fn reset_bridge_on_quit_system<T: FrontendTransport>(game_view: Option<Res<GameView>>, mut bridge: ResMut<RenderBridge<T>>) {
|
||||
// If GameView doesn't exist but bridge is initialized, reset it
|
||||
if game_view.is_none() && bridge.initialized {
|
||||
bridge.reset();
|
||||
tracing::debug!("RenderBridge reset - ready for next game initialization");
|
||||
}
|
||||
}
|
||||
345
crates/borders-core/src/ui/protocol.rs
Normal file
@@ -0,0 +1,345 @@
|
||||
//! Protocol for frontend-backend communication
|
||||
//!
|
||||
//! This module defines the bidirectional message protocol used for communication
|
||||
//! between the game core (Bevy/Rust) and the frontend (PixiJS/TypeScript).
|
||||
|
||||
use bevy_ecs::message::Message;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::game::NationId;
|
||||
|
||||
/// All messages sent from backend to frontend
|
||||
/// Binary data (terrain, territory, nation palette, deltas) are sent via separate binary channels
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Message)]
|
||||
#[serde(tag = "msg_type")]
|
||||
pub enum BackendMessage {
|
||||
/// Complete leaderboard snapshot (includes names, colors, and stats)
|
||||
LeaderboardSnapshot(LeaderboardSnapshot),
|
||||
/// Dynamic attacks updates
|
||||
AttacksUpdate(AttacksUpdatePayload),
|
||||
/// Active ships on the map
|
||||
ShipsUpdate(ShipsUpdatePayload),
|
||||
/// Game has ended with the specified outcome
|
||||
GameEnded { outcome: GameOutcome },
|
||||
/// Spawn phase update
|
||||
/// - countdown: None = phase active, waiting for first spawn
|
||||
/// - countdown: Some = countdown in progress with epoch timestamp
|
||||
SpawnPhaseUpdate { countdown: Option<SpawnCountdown> },
|
||||
/// Spawn phase has ended, game is now active
|
||||
SpawnPhaseEnded,
|
||||
/// Highlight a specific nation (None to clear)
|
||||
HighlightNation { nation_id: Option<NationId> },
|
||||
}
|
||||
|
||||
/// All messages sent from frontend to backend
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Message)]
|
||||
#[serde(tag = "msg_type")]
|
||||
pub enum FrontendMessage {
|
||||
/// Start a new game
|
||||
StartGame,
|
||||
/// Quit the current game and return to menu
|
||||
QuitGame,
|
||||
/// Set attack ratio (percentage of troops to use when attacking)
|
||||
SetAttackRatio { ratio: f32 },
|
||||
}
|
||||
|
||||
/// Terrain types for map tiles
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum TerrainType {
|
||||
Water = 0,
|
||||
Land = 1,
|
||||
Mountain = 2,
|
||||
}
|
||||
|
||||
/// Encode complete initialization data into binary format for channel streaming
|
||||
///
|
||||
/// This combines terrain, territory, and nation palette data into a single atomic payload
|
||||
/// to avoid synchronization issues with multiple messages.
|
||||
///
|
||||
/// Format: [terrain_len:4][terrain_data][territory_len:4][territory_data][nation_palette_count:2][nation_palette_rgb:N*3]
|
||||
///
|
||||
/// Terrain data format: [width:2][height:2][tile_ids:N][palette_count:2][palette_rgb:N*3]
|
||||
/// Territory data format: [count:4][tiles...] where tiles = [index:4][owner:2]
|
||||
///
|
||||
/// All integers are little-endian
|
||||
pub fn encode_init_binary(size: glam::U16Vec2, tile_ids: &[u8], terrain_palette: &[RgbColor], territories: &[crate::game::TileOwnership], nation_palette: &[RgbColor]) -> Vec<u8> {
|
||||
let tile_count = (size.x as usize) * (size.y as usize);
|
||||
assert_eq!(tile_ids.len(), tile_count, "Tile ID count mismatch");
|
||||
|
||||
let terrain_palette_count = terrain_palette.len();
|
||||
assert!(terrain_palette_count <= u16::MAX as usize, "Terrain palette too large");
|
||||
|
||||
// Build terrain data
|
||||
let terrain_size = 2 + 2 + tile_count + 2 + (terrain_palette_count * 3);
|
||||
let mut terrain_data = Vec::with_capacity(terrain_size);
|
||||
|
||||
terrain_data.extend_from_slice(&size.x.to_le_bytes());
|
||||
terrain_data.extend_from_slice(&size.y.to_le_bytes());
|
||||
terrain_data.extend_from_slice(tile_ids);
|
||||
terrain_data.extend_from_slice(&(terrain_palette_count as u16).to_le_bytes());
|
||||
for color in terrain_palette {
|
||||
terrain_data.extend_from_slice(&[color.r, color.g, color.b]);
|
||||
}
|
||||
|
||||
// Build territory data (only player-owned tiles, filter out unclaimed)
|
||||
let claimed_tiles: Vec<(u32, u16)> = territories.iter().enumerate().filter_map(|(index, &ownership)| ownership.nation_id().map(|nation_id| (index as u32, nation_id.get()))).collect();
|
||||
|
||||
let territory_count = claimed_tiles.len() as u32;
|
||||
let territory_size = 4 + (claimed_tiles.len() * 6);
|
||||
let mut territory_data = Vec::with_capacity(territory_size);
|
||||
|
||||
territory_data.extend_from_slice(&territory_count.to_le_bytes());
|
||||
for (index, owner) in claimed_tiles {
|
||||
territory_data.extend_from_slice(&index.to_le_bytes());
|
||||
territory_data.extend_from_slice(&owner.to_le_bytes());
|
||||
}
|
||||
|
||||
// Build nation palette data
|
||||
let nation_palette_count = nation_palette.len();
|
||||
assert!(nation_palette_count <= u16::MAX as usize, "Nation palette too large");
|
||||
|
||||
// Combine into single payload with length prefixes
|
||||
let total_size = 4 + terrain_data.len() + 4 + territory_data.len() + 2 + (nation_palette_count * 3);
|
||||
let mut data = Vec::with_capacity(total_size);
|
||||
|
||||
data.extend_from_slice(&(terrain_data.len() as u32).to_le_bytes());
|
||||
data.extend_from_slice(&terrain_data);
|
||||
data.extend_from_slice(&(territory_data.len() as u32).to_le_bytes());
|
||||
data.extend_from_slice(&territory_data);
|
||||
|
||||
// Append nation palette
|
||||
data.extend_from_slice(&(nation_palette_count as u16).to_le_bytes());
|
||||
for color in nation_palette {
|
||||
data.extend_from_slice(&[color.r, color.g, color.b]);
|
||||
}
|
||||
|
||||
data
|
||||
}
|
||||
|
||||
/// A single tile change in the territory map
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct TileChange {
|
||||
/// Tile index (row * width + col)
|
||||
pub index: u32,
|
||||
/// New owner ID (0-65534 for nations, 65535 for unclaimed)
|
||||
pub owner_id: u16,
|
||||
}
|
||||
|
||||
/// Binary format for efficient territory delta streaming
|
||||
/// This is for the pixel streaming channel, separate from JSON messages
|
||||
#[derive(Debug)]
|
||||
pub struct BinaryTerritoryDelta {
|
||||
/// Raw bytes: [turn:8][count:4][changes...]
|
||||
/// Each change: [index:4][owner:2] = 6 bytes
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl BinaryTerritoryDelta {
|
||||
/// Create binary delta from territory changes
|
||||
pub fn encode(turn: u64, changes: &[TileChange]) -> Vec<u8> {
|
||||
let count = changes.len() as u32;
|
||||
let mut data = Vec::with_capacity(12 + changes.len() * 6);
|
||||
|
||||
// Header: turn (8 bytes) + count (4 bytes)
|
||||
data.extend_from_slice(&turn.to_le_bytes());
|
||||
data.extend_from_slice(&count.to_le_bytes());
|
||||
|
||||
// Changes: each is index (4 bytes) + owner (2 bytes)
|
||||
for change in changes {
|
||||
data.extend_from_slice(&change.index.to_le_bytes());
|
||||
data.extend_from_slice(&change.owner_id.to_le_bytes());
|
||||
}
|
||||
|
||||
data
|
||||
}
|
||||
|
||||
/// Decode binary delta back to structured format
|
||||
pub fn decode(data: &[u8]) -> Option<(u64, Vec<TileChange>)> {
|
||||
if data.len() < 12 {
|
||||
return None; // Not enough data for header
|
||||
}
|
||||
|
||||
let turn = u64::from_le_bytes([data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7]]);
|
||||
|
||||
let count = u32::from_le_bytes([data[8], data[9], data[10], data[11]]) as usize;
|
||||
|
||||
let expected_size = 12 + count * 6;
|
||||
if data.len() != expected_size {
|
||||
return None; // Invalid size
|
||||
}
|
||||
|
||||
let mut changes = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let offset = 12 + i * 6;
|
||||
let index = u32::from_le_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]);
|
||||
let owner_id = u16::from_le_bytes([data[offset + 4], data[offset + 5]]);
|
||||
changes.push(TileChange { index, owner_id });
|
||||
}
|
||||
|
||||
Some((turn, changes))
|
||||
}
|
||||
}
|
||||
|
||||
/// RGB color for player palette
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct RgbColor {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
}
|
||||
|
||||
/// Queries sent from frontend to backend about the map
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum MapQuery {
|
||||
/// Get owner of tile at world coordinates
|
||||
GetOwnerAt { x: f32, y: f32 },
|
||||
/// Get detailed tile info by index
|
||||
GetTileInfo { tile_index: u32 },
|
||||
/// Find any tile owned by player (for camera centering)
|
||||
FindPlayerTerritory { player_id: NationId },
|
||||
/// Convert screen coordinates to tile index
|
||||
ScreenToTile { screen_x: f32, screen_y: f32 },
|
||||
}
|
||||
|
||||
/// Input event sent from frontend to backend
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum RenderInputEvent {
|
||||
/// Mouse click on the map
|
||||
MapClick {
|
||||
/// Tile index clicked (if over a valid tile)
|
||||
tile_index: Option<u32>,
|
||||
/// World coordinates of click
|
||||
world_x: f32,
|
||||
world_y: f32,
|
||||
/// Mouse button (0=left, 1=middle, 2=right)
|
||||
button: u8,
|
||||
},
|
||||
/// Key press event
|
||||
KeyPress {
|
||||
/// Key code as string (e.g., "KeyC", "Digit1")
|
||||
key: String,
|
||||
/// Whether key is pressed (true) or released (false)
|
||||
pressed: bool,
|
||||
},
|
||||
/// Mouse moved over map
|
||||
MapHover {
|
||||
/// Tile index under cursor (if any)
|
||||
tile_index: Option<u32>,
|
||||
/// World coordinates
|
||||
world_x: f32,
|
||||
world_y: f32,
|
||||
},
|
||||
}
|
||||
|
||||
/// Unified leaderboard entry containing both static and dynamic data
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LeaderboardEntry {
|
||||
pub id: NationId,
|
||||
pub name: String,
|
||||
pub color: String, // Hex color without alpha, e.g. "0A44FF"
|
||||
pub tile_count: u32,
|
||||
pub troops: u32,
|
||||
pub territory_percent: f32,
|
||||
pub rank: usize, // Current rank (1-indexed, updates every tick)
|
||||
pub display_order: usize, // Visual position (0-indexed, updates every 3rd tick)
|
||||
}
|
||||
|
||||
/// Complete leaderboard snapshot (replaces separate Init/Update)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LeaderboardSnapshot {
|
||||
pub turn: u64,
|
||||
pub total_land_tiles: u32,
|
||||
pub entries: Vec<LeaderboardEntry>,
|
||||
pub client_player_id: NationId,
|
||||
}
|
||||
|
||||
/// Outcome of the game
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum GameOutcome {
|
||||
/// Player won the game
|
||||
Victory,
|
||||
/// Player lost the game
|
||||
Defeat,
|
||||
}
|
||||
|
||||
/// Single attack entry for attacks UI
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AttackEntry {
|
||||
pub id: u64,
|
||||
pub attacker_id: NationId,
|
||||
pub target_id: Option<NationId>, // None for unclaimed territory
|
||||
pub troops: u32,
|
||||
pub is_outgoing: bool,
|
||||
}
|
||||
|
||||
/// Attacks update payload
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AttacksUpdatePayload {
|
||||
pub turn: u64,
|
||||
pub entries: Vec<AttackEntry>,
|
||||
}
|
||||
|
||||
/// Ships update payload with lifecycle variants
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShipsUpdatePayload {
|
||||
pub turn: u64,
|
||||
pub updates: Vec<ShipUpdateVariant>,
|
||||
}
|
||||
|
||||
/// Ship update variants for efficient delta updates
|
||||
/// NOTE: SHIP_TICKS_PER_TILE (1) must be synchronized between backend and frontend
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ShipUpdateVariant {
|
||||
/// Ship created - full initial state
|
||||
Create {
|
||||
id: u32,
|
||||
owner_id: NationId,
|
||||
path: Vec<u32>,
|
||||
troops: u32, // Static value, currently unused for rendering
|
||||
},
|
||||
/// Ship moved to next tile in path
|
||||
Move { id: u32, current_path_index: u32 },
|
||||
/// Ship destroyed (arrived or cancelled)
|
||||
Destroy { id: u32 },
|
||||
}
|
||||
|
||||
// TODO: On client reconnection/late-join, send Create variants for all active ships
|
||||
|
||||
/// Countdown state for spawn phase
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SpawnCountdown {
|
||||
pub started_at_ms: u64,
|
||||
pub duration_secs: f32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_binary_delta_encoding() {
|
||||
let changes = vec![
|
||||
TileChange { index: 100, owner_id: 1 },
|
||||
TileChange { index: 200, owner_id: 2 },
|
||||
TileChange { index: 300, owner_id: 0 },
|
||||
TileChange {
|
||||
index: 400,
|
||||
owner_id: 65535, // Unclaimed
|
||||
},
|
||||
];
|
||||
|
||||
let encoded = BinaryTerritoryDelta::encode(42, &changes);
|
||||
assert_eq!(encoded.len(), 12 + 4 * 6);
|
||||
|
||||
let decoded = BinaryTerritoryDelta::decode(&encoded).unwrap();
|
||||
assert_eq!(decoded.0, 42);
|
||||
assert_eq!(decoded.1.len(), 4);
|
||||
assert_eq!(decoded.1[0].index, 100);
|
||||
assert_eq!(decoded.1[0].owner_id, 1);
|
||||
assert_eq!(decoded.1[3].owner_id, 65535); // Unclaimed
|
||||
}
|
||||
}
|
||||
219
crates/borders-core/src/ui/transport.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
//! Shared render bridge infrastructure for platform-agnostic rendering
|
||||
//!
|
||||
//! This module provides the common logic for rendering bridges across platforms
|
||||
//! (WASM, Tauri, etc.), with platform-specific transport mechanisms abstracted
|
||||
//! behind the RenderBridgeTransport trait.
|
||||
|
||||
use crate::game::TerrainData;
|
||||
use crate::game::view::GameView;
|
||||
use crate::ui::protocol::{BackendMessage, BinaryTerritoryDelta, FrontendMessage, RenderInputEvent, RgbColor, TileChange};
|
||||
use bevy_ecs::prelude::*;
|
||||
use tracing::{error, info, trace, warn};
|
||||
|
||||
/// Trait for platform-specific frontend communication
|
||||
///
|
||||
/// This abstracts the actual mechanism for bidirectional frontend communication,
|
||||
/// allowing WASM (JS callbacks), Tauri (events), and other platforms to implement
|
||||
/// their own transport while sharing the core logic.
|
||||
///
|
||||
/// All platforms use binary channels for terrain/territory data to ensure
|
||||
/// consistent encoding/decoding and optimal performance.
|
||||
pub trait FrontendTransport: Send + Sync + Clone + 'static {
|
||||
/// Send a message from backend to frontend (JSON-serialized control messages)
|
||||
fn send_backend_message(&self, message: &BackendMessage) -> Result<(), String>;
|
||||
|
||||
/// Send binary initialization data (terrain + territory) for initial load
|
||||
///
|
||||
/// Format: [terrain_len:4][terrain_data][territory_len:4][territory_data]
|
||||
/// See `encode_init_binary` for details.
|
||||
fn send_init_binary(&self, data: Vec<u8>) -> Result<(), String>;
|
||||
|
||||
/// Send binary territory delta data
|
||||
///
|
||||
/// Format: [turn:8][count:4][changes...]
|
||||
/// See `BinaryTerritoryDelta::encode` for details.
|
||||
fn send_binary_delta(&self, data: Vec<u8>) -> Result<(), String>;
|
||||
|
||||
/// Try to receive a message from the frontend
|
||||
///
|
||||
/// Returns `Some(message)` if a message is available, `None` if not.
|
||||
/// This should be non-blocking and called frequently (e.g., every frame).
|
||||
fn try_recv_frontend_message(&self) -> Option<FrontendMessage>;
|
||||
}
|
||||
|
||||
/// Resource for managing frontend communication state
|
||||
#[derive(Resource)]
|
||||
pub struct RenderBridge<T: FrontendTransport> {
|
||||
pub transport: T,
|
||||
/// Track if we've sent initial data
|
||||
pub(crate) initialized: bool,
|
||||
}
|
||||
|
||||
impl<T: FrontendTransport> RenderBridge<T> {
|
||||
pub fn new(transport: T) -> Self {
|
||||
Self { transport, initialized: false }
|
||||
}
|
||||
|
||||
/// Reset the bridge to allow re-initialization
|
||||
/// This should be called when a game is quit to ensure fresh data is sent on next game start
|
||||
pub fn reset(&mut self) {
|
||||
self.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// System to send initial render data (terrain, palette, initial territories)
|
||||
pub fn send_initial_render_data<T: FrontendTransport>(game_view: If<Res<GameView>>, terrain_data: If<Res<TerrainData>>, mut bridge: If<ResMut<RenderBridge<T>>>) {
|
||||
// Early return if already initialized - prevents duplicate sends
|
||||
if bridge.initialized {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't send initial data for empty game view
|
||||
if game_view.width() == 0 || game_view.height() == 0 || game_view.players.is_empty() {
|
||||
trace!("send_initial_render_data: GameView not yet populated, waiting...");
|
||||
return;
|
||||
}
|
||||
|
||||
let _guard = tracing::debug_span!(
|
||||
"send_initial_render_data",
|
||||
size = ?game_view.size(),
|
||||
player_count = game_view.players.len()
|
||||
)
|
||||
.entered();
|
||||
|
||||
// Mark as initialized FIRST to prevent re-execution even if send fails
|
||||
// This is important because the frontend callback might not be registered yet
|
||||
// on the first few frames, causing send to fail but we don't want to rebuild
|
||||
// the expensive RenderInit message multiple times
|
||||
bridge.initialized = true;
|
||||
|
||||
// Prepare terrain data
|
||||
let size = game_view.size();
|
||||
let tile_ids = terrain_data.get_tile_ids();
|
||||
let palette_colors: Vec<RgbColor> = terrain_data.get_terrain_palette_colors().into_iter().map(|[r, g, b]| RgbColor { r, g, b }).collect();
|
||||
|
||||
info!("Terrain palette: {} colors", palette_colors.len());
|
||||
|
||||
// Build nation palette
|
||||
let nation_palette = {
|
||||
let _guard = tracing::trace_span!("build_player_palette").entered();
|
||||
|
||||
// Allocate only enough space for active players + a small buffer
|
||||
let max_player_id = game_view.players.iter().map(|p| p.id.get()).max().unwrap_or(0) as usize;
|
||||
|
||||
// Allocate palette size as: max(256, max_player_id + 1) to handle typical player counts
|
||||
let palette_size = (max_player_id + 1).max(256);
|
||||
let mut colors = vec![RgbColor { r: 0, g: 0, b: 0 }; palette_size];
|
||||
|
||||
for player in &game_view.players {
|
||||
colors[player.id.get() as usize] = RgbColor { r: (player.color[0] * 255.0) as u8, g: (player.color[1] * 255.0) as u8, b: (player.color[2] * 255.0) as u8 };
|
||||
}
|
||||
colors
|
||||
};
|
||||
|
||||
// Send terrain, territory, and nation palette via binary channel (both WASM and Tauri)
|
||||
{
|
||||
let _guard = tracing::trace_span!("send_init_binary", terrain_size = tile_ids.len(), territory_size = game_view.territories.len(), nation_palette_size = nation_palette.len()).entered();
|
||||
|
||||
let binary_init = crate::ui::protocol::encode_init_binary(size, tile_ids, &palette_colors, &game_view.territories, &nation_palette);
|
||||
|
||||
if let Err(e) = bridge.transport.send_init_binary(binary_init) {
|
||||
error!("Failed to send init binary data: {}", e);
|
||||
bridge.initialized = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
info!("Initialization data sent successfully via binary channel (terrain + territory + nation palette)");
|
||||
}
|
||||
|
||||
/// System to detect and stream territory changes
|
||||
pub fn stream_territory_deltas<T: FrontendTransport>(game_view: If<Res<GameView>>, bridge: If<Res<RenderBridge<T>>>) {
|
||||
// Gate: Don't send deltas until initial render data has been sent
|
||||
if !bridge.initialized {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if GameView hasn't changed
|
||||
if !game_view.is_changed() {
|
||||
return;
|
||||
}
|
||||
|
||||
let _guard = tracing::debug_span!("stream_territory_deltas").entered();
|
||||
|
||||
if !game_view.changed_tiles.is_empty() {
|
||||
let turn = game_view.turn_number;
|
||||
|
||||
// Build delta from the pre-tracked changes
|
||||
// Include ALL changed tiles, both owned and unclaimed (65535)
|
||||
let changes: Vec<TileChange> = game_view
|
||||
.changed_tiles
|
||||
.iter()
|
||||
.map(|&index| {
|
||||
let ownership = game_view.get_ownership(index);
|
||||
let owner_id: u16 = ownership.into();
|
||||
TileChange { index, owner_id }
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Send binary delta (used by both WASM and Tauri)
|
||||
let binary_data = BinaryTerritoryDelta::encode(turn, &changes);
|
||||
if let Err(e) = bridge.transport.send_binary_delta(binary_data) {
|
||||
error!("Failed to send binary territory delta: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle render input events from the frontend
|
||||
///
|
||||
/// This function processes input events and updates the shared InputState.
|
||||
/// It should be called from platform-specific command handlers.
|
||||
pub fn handle_render_input(event: &RenderInputEvent, input_state: &mut crate::ui::input::InputState, map_width: u16) -> Result<(), String> {
|
||||
match event {
|
||||
RenderInputEvent::MapClick { tile_index, world_x, world_y, button } => {
|
||||
if let Some(button) = crate::ui::input::MouseButton::from_u8(*button) {
|
||||
let world_pos = crate::ui::input::WorldPos { x: *world_x, y: *world_y };
|
||||
|
||||
let tile_coord = tile_index.map(|idx| crate::ui::input::tile_from_index(idx as usize, map_width));
|
||||
|
||||
input_state.handle_event(crate::ui::input::InputEvent::MouseButton { button, state: crate::ui::input::ButtonState::Released, world_pos: Some(world_pos), tile: tile_coord });
|
||||
}
|
||||
}
|
||||
RenderInputEvent::KeyPress { key, pressed } => {
|
||||
if let Some(key_code) = crate::ui::input::KeyCode::from_string(key) {
|
||||
let button_state = if *pressed { crate::ui::input::ButtonState::Pressed } else { crate::ui::input::ButtonState::Released };
|
||||
|
||||
input_state.handle_event(crate::ui::input::InputEvent::KeyPress { key: key_code, state: button_state });
|
||||
}
|
||||
}
|
||||
RenderInputEvent::MapHover { tile_index, world_x, world_y } => {
|
||||
let world_pos = crate::ui::input::WorldPos { x: *world_x, y: *world_y };
|
||||
|
||||
let tile_coord = tile_index.map(|idx| crate::ui::input::tile_from_index(idx as usize, map_width));
|
||||
|
||||
input_state.handle_event(crate::ui::input::InputEvent::MouseMove {
|
||||
world_pos,
|
||||
screen_pos: crate::ui::input::ScreenPos { x: 0.0, y: 0.0 }, // Not used
|
||||
tile: tile_coord,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// System that reads BackendMessage events and sends them through the transport
|
||||
pub(crate) fn emit_backend_messages_system<T: FrontendTransport>(mut events: MessageReader<BackendMessage>, bridge: Res<RenderBridge<T>>) {
|
||||
for event in events.read() {
|
||||
if let Err(e) = bridge.transport.send_backend_message(event) {
|
||||
warn!("Failed to send backend message through transport: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System that polls the transport for incoming frontend messages and emits them as events
|
||||
pub(crate) fn ingest_frontend_messages_system<T: FrontendTransport>(mut messages: MessageWriter<FrontendMessage>, bridge: Res<RenderBridge<T>>) {
|
||||
while let Some(message) = bridge.transport.try_recv_frontend_message() {
|
||||
messages.write(message);
|
||||
}
|
||||
}
|
||||
37
crates/borders-core/tests/terrain.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use borders_core::game::terrain::data::{MapManifest, MapMetadata, TerrainData, TileType};
|
||||
use borders_core::game::world::tilemap::TileMap;
|
||||
|
||||
fn create_test_terrain(width: usize, height: usize) -> TerrainData {
|
||||
let tile_types = vec![TileType { name: "water".to_string(), color_base: "blue".to_string(), color_variant: 0, conquerable: false, navigable: true, expansion_time: 255, expansion_cost: 255 }, TileType { name: "land".to_string(), color_base: "green".to_string(), color_variant: 0, conquerable: true, navigable: false, expansion_time: 50, expansion_cost: 50 }];
|
||||
|
||||
let mut terrain_data_raw = vec![0; width * height];
|
||||
terrain_data_raw[5] = 0x80; // Make position 5 land
|
||||
terrain_data_raw[10] = 0x85; // Make position 10 land with magnitude 5
|
||||
|
||||
let tiles: Vec<u8> = terrain_data_raw.iter().map(|&byte| if byte & 0x80 != 0 { 1 } else { 0 }).collect();
|
||||
|
||||
let terrain_data = TileMap::from_vec(width as u16, height as u16, terrain_data_raw);
|
||||
|
||||
TerrainData { _manifest: MapManifest { map: MapMetadata { size: glam::U16Vec2::new(width as u16, height as u16), num_land_tiles: 2 }, name: "Test".to_string(), nations: Vec::new() }, terrain_data, tiles, tile_types }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_land() {
|
||||
let terrain = create_test_terrain(10, 10);
|
||||
assert!(!terrain.is_land(glam::U16Vec2::new(0, 0)));
|
||||
assert!(terrain.is_land(glam::U16Vec2::new(5, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_conquerable() {
|
||||
let terrain = create_test_terrain(10, 10);
|
||||
assert!(!terrain.is_conquerable(glam::U16Vec2::new(0, 0)));
|
||||
assert!(terrain.is_conquerable(glam::U16Vec2::new(5, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_navigable() {
|
||||
let terrain = create_test_terrain(10, 10);
|
||||
assert!(terrain.is_navigable(glam::U16Vec2::new(0, 0)));
|
||||
assert!(!terrain.is_navigable(glam::U16Vec2::new(5, 0)));
|
||||
}
|
||||
7
crates/borders-desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
29
crates/borders-desktop/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "borders-desktop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
tracy = ["dep:tracing-tracy", "dep:tracy-client"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
bevy_ecs = { version = "0.17", default-features = false, features = ["std"] }
|
||||
borders-core = { path = "../borders-core", features = ["ui"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tokio = { version = "1", features = ["time"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-tracy = { version = "0.11", default-features = false, optional = true }
|
||||
tracy-client = { version = "0.18.2", optional = true }
|
||||
|
||||
[package.metadata.cargo-machete]
|
||||
ignored = ["tauri-build"]
|
||||
3
crates/borders-desktop/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
17
crates/borders-desktop/capabilities/default.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
{
|
||||
"identifier": "opener:allow-open-url",
|
||||
"allow": [
|
||||
{
|
||||
"url": "https://github.com/Xevion"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
crates/borders-desktop/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
crates/borders-desktop/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
crates/borders-desktop/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
crates/borders-desktop/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
crates/borders-desktop/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
crates/borders-desktop/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
crates/borders-desktop/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
crates/borders-desktop/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
crates/borders-desktop/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
crates/borders-desktop/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
crates/borders-desktop/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
crates/borders-desktop/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
crates/borders-desktop/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
crates/borders-desktop/icons/icon.icns
Normal file
BIN
crates/borders-desktop/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
crates/borders-desktop/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
51
crates/borders-desktop/src/analytics.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use borders_core::telemetry::{self, TelemetryEvent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tauri::Manager;
|
||||
|
||||
/// Analytics event from the frontend
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnalyticsEventPayload {
|
||||
pub event: String,
|
||||
#[serde(default)]
|
||||
pub properties: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Tauri command to track analytics events from the frontend
|
||||
#[tauri::command]
|
||||
pub async fn track_analytics_event(payload: AnalyticsEventPayload) -> Result<(), String> {
|
||||
tracing::debug!("Tracking analytics event: {}", payload.event);
|
||||
|
||||
let event = TelemetryEvent { event: payload.event, properties: payload.properties };
|
||||
|
||||
// Track the event asynchronously (Tauri handles the async context)
|
||||
telemetry::track(event).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tauri command to flush pending analytics events
|
||||
#[tauri::command]
|
||||
pub async fn flush_analytics() -> Result<(), String> {
|
||||
if let Some(client) = telemetry::client() {
|
||||
client.flush().await;
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Telemetry client not initialized".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Tauri command to request app exit
|
||||
///
|
||||
/// Simply closes the window - analytics flush happens in ExitRequested event handler
|
||||
#[tauri::command]
|
||||
pub async fn request_exit(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
tracing::debug!("Exit requested via command");
|
||||
|
||||
// Close the window (will trigger ExitRequested event → analytics flush)
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
window.close().map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
107
crates/borders-desktop/src/main.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use borders_core::app::App;
|
||||
use borders_core::time::Time;
|
||||
|
||||
use crate::plugin::{TauriPlugin, generate_tauri_context};
|
||||
|
||||
mod analytics;
|
||||
mod plugin;
|
||||
mod render_bridge;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let mut app = App::new();
|
||||
|
||||
// Initialize time tracking
|
||||
app.insert_resource(Time::new());
|
||||
|
||||
TauriPlugin::new(|| {
|
||||
let _guard = tracing::trace_span!("tauri_build").entered();
|
||||
tauri::Builder::default().plugin(tauri_plugin_opener::init()).plugin(tauri_plugin_process::init()).invoke_handler(tauri::generate_handler![render_bridge::register_binary_init_channel, render_bridge::send_frontend_message, render_bridge::handle_render_input, render_bridge::get_game_state, analytics::track_analytics_event, analytics::flush_analytics, analytics::request_exit,]).build(generate_tauri_context()).expect("error while building tauri application")
|
||||
})
|
||||
.build_and_run(app);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Initialize tracing before Bevy
|
||||
#[cfg(feature = "tracy")]
|
||||
{
|
||||
use tracing_subscriber::fmt::format::DefaultFields;
|
||||
// Initialize Tracy profiler client
|
||||
let _ = tracy_client::Client::start();
|
||||
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
|
||||
struct BareTracyConfig {
|
||||
fmt: DefaultFields,
|
||||
}
|
||||
|
||||
impl tracing_tracy::Config for BareTracyConfig {
|
||||
type Formatter = DefaultFields;
|
||||
|
||||
fn formatter(&self) -> &Self::Formatter {
|
||||
&self.fmt
|
||||
}
|
||||
|
||||
fn format_fields_in_zone_name(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
let tracy_layer = tracing_tracy::TracyLayer::new(BareTracyConfig { fmt: DefaultFields::default() });
|
||||
|
||||
tracing::subscriber::set_global_default(tracing_subscriber::registry().with(tracy_layer)).expect("setup tracy layer");
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "tracy"))]
|
||||
{
|
||||
use tracing_subscriber::fmt::time::FormatTime;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
let log_filter = "borders_core=debug,borders_protocol=debug,borders_desktop=debug,iron_borders=debug,info";
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
let log_filter = "borders_core=warn,borders_protocol=warn,iron_borders=warn,error";
|
||||
|
||||
struct CustomTimeFormat;
|
||||
|
||||
impl FormatTime for CustomTimeFormat {
|
||||
fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> std::fmt::Result {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
|
||||
let total_secs = now.as_secs();
|
||||
let nanos = now.subsec_nanos();
|
||||
|
||||
let secs_in_day = total_secs % 86400;
|
||||
let hours = secs_in_day / 3600;
|
||||
let minutes = (secs_in_day % 3600) / 60;
|
||||
let seconds = secs_in_day % 60;
|
||||
let millis = nanos / 1_000_000;
|
||||
let micros = (nanos / 1_000) % 1_000;
|
||||
|
||||
write!(w, "{:02}:{:02}:{:02}.{:03}{:03}", hours, minutes, seconds, millis, micros)
|
||||
}
|
||||
}
|
||||
|
||||
tracing_subscriber::registry().with(tracing_subscriber::EnvFilter::new(log_filter)).with(tracing_subscriber::fmt::layer().with_timer(CustomTimeFormat)).init();
|
||||
}
|
||||
|
||||
// Log build information
|
||||
tracing::info!(git_commit = borders_core::build_info::git_commit_short(), build_time = borders_core::build_info::BUILD_TIME, "Iron Borders v{} © 2025 Ryan Walters. All Rights Reserved.", borders_core::build_info::VERSION);
|
||||
|
||||
// Initialize telemetry in background (non-blocking)
|
||||
std::thread::spawn(|| {
|
||||
let _guard = tracing::trace_span!("telemetry_init").entered();
|
||||
tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||
borders_core::telemetry::init(borders_core::telemetry::TelemetryConfig::default()).await;
|
||||
borders_core::telemetry::track_session_start().await;
|
||||
tracing::info!("Observability ready");
|
||||
});
|
||||
});
|
||||
|
||||
run();
|
||||
}
|
||||
196
crates/borders-desktop/src/plugin.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
//! Tauri-Bevy integration plugin
|
||||
//!
|
||||
//! This module provides the main integration between Tauri and Bevy, handling
|
||||
//! the main application loop and event bridging.
|
||||
|
||||
use borders_core::app::{App, Plugin, Update};
|
||||
use borders_core::time::Time;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tauri::{Manager, RunEvent};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::time::Instant;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use web_time::Instant;
|
||||
|
||||
use crate::render_bridge::{TauriTransport, cache_leaderboard_snapshot_system};
|
||||
|
||||
const TARGET_FPS: f64 = 60.0;
|
||||
|
||||
pub fn generate_tauri_context() -> tauri::Context {
|
||||
tauri::generate_context!()
|
||||
}
|
||||
|
||||
fn setup_tauri_integration(app: &mut App, tauri_app: &tauri::AppHandle, shared_leaderboard_state: Arc<Mutex<Option<borders_core::ui::protocol::LeaderboardSnapshot>>>) {
|
||||
let _guard = tracing::trace_span!("setup_tauri_integration").entered();
|
||||
tracing::debug!("Setup tauri integration");
|
||||
|
||||
// Register state for render bridge commands
|
||||
tauri_app.manage(Arc::new(Mutex::new(None::<borders_core::game::view::GameView>)));
|
||||
|
||||
// InputState - shared between Tauri commands and ECS systems
|
||||
let input_state_shared = Arc::new(Mutex::new(borders_core::ui::input::InputState::new()));
|
||||
tauri_app.manage(input_state_shared.clone());
|
||||
app.insert_non_send_resource(input_state_shared);
|
||||
|
||||
// Register shared state with Tauri (for get_game_state command)
|
||||
tauri_app.manage(shared_leaderboard_state.clone());
|
||||
|
||||
// Get the message queue and binary init channel from the transport (already added as plugin)
|
||||
let transport = app.world().get_resource::<borders_core::ui::RenderBridge<TauriTransport>>().expect("RenderBridge should be added by plugin");
|
||||
let message_queue = transport.transport.inbound_messages();
|
||||
let binary_init_channel_storage = transport.transport.binary_init_channel();
|
||||
tauri_app.manage(message_queue);
|
||||
tauri_app.manage(binary_init_channel_storage);
|
||||
|
||||
// Store shared states in world
|
||||
app.insert_non_send_resource(shared_leaderboard_state);
|
||||
}
|
||||
|
||||
pub struct TauriPlugin {
|
||||
setup: Box<dyn Fn() -> tauri::App + Send + Sync>,
|
||||
}
|
||||
|
||||
impl TauriPlugin {
|
||||
pub fn new<F>(setup: F) -> Self
|
||||
where
|
||||
F: Fn() -> tauri::App + Send + Sync + 'static,
|
||||
{
|
||||
Self { setup: Box::new(setup) }
|
||||
}
|
||||
}
|
||||
|
||||
impl TauriPlugin {
|
||||
pub fn build_and_run(self, mut app: App) -> ! {
|
||||
let tauri_app = {
|
||||
let _guard = tracing::debug_span!("tauri_plugin_build").entered();
|
||||
|
||||
let tauri_app = (self.setup)();
|
||||
|
||||
// Create shared state for game state recovery (leaderboard only)
|
||||
let shared_leaderboard_state = Arc::new(Mutex::new(None::<borders_core::ui::protocol::LeaderboardSnapshot>));
|
||||
|
||||
// Create transport for Tauri frontend (handles both render and UI communication)
|
||||
let transport = TauriTransport::new(tauri_app.handle().clone());
|
||||
|
||||
// Add the render bridge plugin to handle all frontend communication
|
||||
borders_core::ui::FrontendPlugin::new(transport).build(&mut app);
|
||||
|
||||
// Set up Tauri integration directly (no startup system needed)
|
||||
setup_tauri_integration(&mut app, tauri_app.handle(), shared_leaderboard_state.clone());
|
||||
|
||||
// Add the leaderboard caching system
|
||||
app.add_systems(Update, cache_leaderboard_snapshot_system);
|
||||
|
||||
// Pre-initialize game plugin BEFORE entering the event loop
|
||||
// This prevents a 300ms delay waiting for the first loop iteration
|
||||
{
|
||||
let _guard = tracing::debug_span!("pre_initialize_game").entered();
|
||||
tracing::info!("Pre-initializing game systems...");
|
||||
borders_core::plugin::GamePlugin::new(borders_core::plugin::NetworkMode::Local).build(&mut app);
|
||||
app.run_startup();
|
||||
app.finish();
|
||||
app.cleanup();
|
||||
tracing::info!("Game systems pre-initialized");
|
||||
}
|
||||
|
||||
tauri_app
|
||||
};
|
||||
|
||||
// Run the app (already initialized)
|
||||
run_tauri_app(app, tauri_app, true); // Pass true to skip re-init
|
||||
std::process::exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_tauri_app(app: App, tauri_app: tauri::App, already_initialized: bool) {
|
||||
let app_rc = Rc::new(RefCell::new(app));
|
||||
let mut tauri_app = tauri_app;
|
||||
let mut is_initialized = already_initialized; // Skip init if already done
|
||||
let mut last_frame_time = Instant::now();
|
||||
|
||||
let target_frame_duration = Duration::from_secs_f64(1.0 / TARGET_FPS);
|
||||
|
||||
loop {
|
||||
let _guard = tracing::trace_span!("main_frame").entered();
|
||||
let frame_start = Instant::now();
|
||||
|
||||
#[allow(deprecated)]
|
||||
tauri_app.run_iteration(move |_app_handle, event: RunEvent| {
|
||||
match event {
|
||||
tauri::RunEvent::Ready => {
|
||||
// Event acknowledged, actual setup happens below
|
||||
}
|
||||
tauri::RunEvent::ExitRequested { .. } => {
|
||||
// Track session end and flush analytics before exit
|
||||
if borders_core::telemetry::client().is_some() {
|
||||
tracing::debug!("ExitRequested: tracking session end and flushing analytics");
|
||||
|
||||
// Create a minimal runtime for blocking operations
|
||||
let runtime = tokio::runtime::Builder::new_current_thread().enable_time().enable_io().build().expect("Failed to create tokio runtime for flush");
|
||||
|
||||
runtime.block_on(async {
|
||||
// Track session end event
|
||||
borders_core::telemetry::track_session_end().await;
|
||||
|
||||
// Flush all pending events (the batch-triggered send is now synchronous)
|
||||
if let Some(client) = borders_core::telemetry::client() {
|
||||
let timeout = Duration::from_millis(500);
|
||||
match tokio::time::timeout(timeout, client.flush()).await {
|
||||
Ok(_) => {
|
||||
tracing::debug!("Analytics flushed successfully before exit")
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("Analytics flush timed out after 500ms")
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
});
|
||||
|
||||
if tauri_app.webview_windows().is_empty() {
|
||||
tauri_app.cleanup_before_exit();
|
||||
break;
|
||||
}
|
||||
|
||||
// Initialize game plugin on first iteration after Tauri is ready
|
||||
if !is_initialized {
|
||||
let _guard = tracing::debug_span!("app_initialization").entered();
|
||||
let mut app = app_rc.borrow_mut();
|
||||
|
||||
// Add core game plugin
|
||||
borders_core::plugin::GamePlugin::new(borders_core::plugin::NetworkMode::Local).build(&mut app);
|
||||
|
||||
app.run_startup();
|
||||
app.finish();
|
||||
app.cleanup();
|
||||
|
||||
is_initialized = true;
|
||||
last_frame_time = Instant::now(); // Reset timer after initialization
|
||||
tracing::info!("Game initialized");
|
||||
}
|
||||
|
||||
// Update time resource with delta from PREVIOUS frame
|
||||
let mut app = app_rc.borrow_mut();
|
||||
let delta = frame_start.duration_since(last_frame_time);
|
||||
if let Some(mut time) = app.world_mut().get_resource_mut::<Time>() {
|
||||
time.update(delta);
|
||||
}
|
||||
|
||||
app.update();
|
||||
|
||||
let frame_duration = frame_start.elapsed();
|
||||
if frame_duration < target_frame_duration {
|
||||
std::thread::sleep(target_frame_duration - frame_duration);
|
||||
}
|
||||
|
||||
last_frame_time = frame_start;
|
||||
}
|
||||
}
|
||||
144
crates/borders-desktop/src/render_bridge.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
//! Tauri-specific frontend transport and command handlers
|
||||
//!
|
||||
//! This module provides the Tauri implementation of FrontendTransport,
|
||||
//! along with Tauri command handlers for input events and state recovery.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use bevy_ecs::message::MessageReader;
|
||||
use bevy_ecs::system::NonSend;
|
||||
use borders_core::ui::FrontendTransport;
|
||||
use borders_core::ui::protocol::{BackendMessage, FrontendMessage, LeaderboardSnapshot, RenderInputEvent};
|
||||
use tauri::{AppHandle, Emitter, ipc::Channel};
|
||||
|
||||
/// Storage for the binary init channel used for streaming initialization data
|
||||
pub struct BinaryInitChannelStorage(pub Arc<Mutex<Option<Channel<Vec<u8>>>>>);
|
||||
|
||||
/// Tauri-specific frontend transport using Tauri events
|
||||
#[derive(Clone)]
|
||||
pub struct TauriTransport {
|
||||
app_handle: AppHandle,
|
||||
/// Inbound messages from the frontend
|
||||
inbound_messages: Arc<Mutex<VecDeque<FrontendMessage>>>,
|
||||
/// Binary init channel for streaming initialization data (terrain + territory + nation palette)
|
||||
binary_init_channel: Arc<Mutex<Option<Channel<Vec<u8>>>>>,
|
||||
}
|
||||
|
||||
impl TauriTransport {
|
||||
pub fn new(app_handle: AppHandle) -> Self {
|
||||
Self { app_handle, inbound_messages: Arc::new(Mutex::new(VecDeque::new())), binary_init_channel: Arc::new(Mutex::new(None)) }
|
||||
}
|
||||
|
||||
/// Get a reference to the inbound messages queue (for Tauri command handler)
|
||||
pub fn inbound_messages(&self) -> Arc<Mutex<VecDeque<FrontendMessage>>> {
|
||||
self.inbound_messages.clone()
|
||||
}
|
||||
|
||||
/// Get the binary init channel for registration
|
||||
pub fn binary_init_channel(&self) -> BinaryInitChannelStorage {
|
||||
BinaryInitChannelStorage(self.binary_init_channel.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl FrontendTransport for TauriTransport {
|
||||
fn send_backend_message(&self, message: &BackendMessage) -> Result<(), String> {
|
||||
let _guard = tracing::trace_span!(
|
||||
"tauri_send_backend_message",
|
||||
message_type = match message {
|
||||
BackendMessage::LeaderboardSnapshot(_) => "LeaderboardSnapshot",
|
||||
BackendMessage::AttacksUpdate(_) => "AttacksUpdate",
|
||||
BackendMessage::ShipsUpdate(_) => "ShipsUpdate",
|
||||
BackendMessage::GameEnded { .. } => "GameEnded",
|
||||
BackendMessage::SpawnPhaseUpdate { .. } => "SpawnPhaseUpdate",
|
||||
BackendMessage::SpawnPhaseEnded => "SpawnPhaseEnded",
|
||||
BackendMessage::HighlightNation { .. } => "HighlightNation",
|
||||
}
|
||||
)
|
||||
.entered();
|
||||
|
||||
self.app_handle.emit("backend:message", message).map_err(|e| format!("Failed to emit backend message: {}", e))
|
||||
}
|
||||
|
||||
fn send_init_binary(&self, data: Vec<u8>) -> Result<(), String> {
|
||||
let _guard = tracing::trace_span!("tauri_send_binary_init", size = data.len()).entered();
|
||||
|
||||
let ch_lock = self.binary_init_channel.lock().map_err(|_| "Failed to lock binary init channel")?;
|
||||
let channel = ch_lock.as_ref().ok_or("Binary init channel not registered")?;
|
||||
|
||||
channel.send(data).map_err(|e| format!("Failed to send binary init data via channel: {}", e))
|
||||
}
|
||||
|
||||
fn send_binary_delta(&self, data: Vec<u8>) -> Result<(), String> {
|
||||
let _guard = tracing::trace_span!("tauri_send_binary_delta", size = data.len()).entered();
|
||||
self.app_handle.emit("backend:binary_delta", &data).map_err(|e| format!("Failed to emit binary delta: {}", e))
|
||||
}
|
||||
|
||||
fn try_recv_frontend_message(&self) -> Option<FrontendMessage> {
|
||||
if let Ok(mut messages) = self.inbound_messages.lock() { messages.pop_front() } else { None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Tauri command to register the binary init channel for streaming initialization data
|
||||
#[tauri::command]
|
||||
pub fn register_binary_init_channel(channel: Channel<Vec<u8>>, channel_storage: tauri::State<BinaryInitChannelStorage>) -> Result<(), String> {
|
||||
tracing::info!("Binary init channel registered");
|
||||
channel_storage
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| {
|
||||
tracing::error!("Failed to acquire lock on binary init channel storage");
|
||||
"Failed to acquire lock on binary init channel storage".to_string()
|
||||
})?
|
||||
.replace(channel);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tauri command handler for receiving frontend messages
|
||||
#[tauri::command]
|
||||
pub fn send_frontend_message(message: FrontendMessage, bridge: tauri::State<Arc<Mutex<VecDeque<FrontendMessage>>>>) -> Result<(), String> {
|
||||
tracing::info!("Frontend sent message: {:?}", message);
|
||||
if let Ok(mut messages) = bridge.lock() {
|
||||
messages.push_back(message);
|
||||
tracing::debug!("Message queued, queue size: {}", messages.len());
|
||||
Ok(())
|
||||
} else {
|
||||
tracing::error!("Failed to acquire lock on message queue");
|
||||
Err("Failed to acquire lock on message queue".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle input events from the frontend
|
||||
#[tauri::command]
|
||||
pub fn handle_render_input(event: RenderInputEvent, input_state: tauri::State<Arc<Mutex<borders_core::ui::input::InputState>>>) -> Result<(), String> {
|
||||
let mut state = input_state.lock().map_err(|e| format!("Failed to lock input state: {}", e))?;
|
||||
|
||||
// TODO: Get actual map width from GameView or TerrainData
|
||||
let map_width = 2560; // Placeholder
|
||||
|
||||
borders_core::ui::handle_render_input(&event, &mut state, map_width)
|
||||
}
|
||||
|
||||
/// Get current game state for frontend recovery after reload
|
||||
/// Note: Initialization data (terrain, territory, nation palette) is not recoverable after reload.
|
||||
/// The frontend must wait for a fresh game to start.
|
||||
#[tauri::command]
|
||||
pub fn get_game_state(leaderboard_state: tauri::State<Arc<Mutex<Option<LeaderboardSnapshot>>>>) -> Result<Option<LeaderboardSnapshot>, String> {
|
||||
leaderboard_state.lock().map(|state| state.clone()).map_err(|e| format!("Failed to lock leaderboard state: {}", e))
|
||||
}
|
||||
|
||||
/// System to cache leaderboard snapshots for state recovery
|
||||
pub fn cache_leaderboard_snapshot_system(mut events: MessageReader<BackendMessage>, shared_leaderboard_state: Option<NonSend<Arc<Mutex<Option<LeaderboardSnapshot>>>>>) {
|
||||
let Some(shared_state) = shared_leaderboard_state else {
|
||||
return;
|
||||
};
|
||||
|
||||
for event in events.read() {
|
||||
if let BackendMessage::LeaderboardSnapshot(snapshot) = event
|
||||
&& let Ok(mut state) = shared_state.lock()
|
||||
{
|
||||
*state = Some(snapshot.clone());
|
||||
tracing::trace!("Cached leaderboard snapshot for state recovery");
|
||||
}
|
||||
}
|
||||
}
|
||||