Files
smart-rgb/crates/borders-core/src/plugin.rs
2025-10-25 16:15:50 -05:00

599 lines
29 KiB
Rust

//! 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::sync::{Arc, Mutex};
use bevy_ecs::prelude::*;
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::TurnGenerator;
use crate::networking::{IntentEvent, ProcessTurnEvent};
use crate::time::{FixedTime, Time};
use crate::game::core::constants::game::TICK_INTERVAL;
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, handle_spawns_system, process_and_apply_actions_system, process_player_income_system, tick_attacks_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};
// 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.before(update_current_turn_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>();
// 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));
// 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(),
);
// Turn execution systems - split into two chains due to Bevy's tuple size limit
// Must run after update_current_turn_system so CurrentTurn exists for turn_is_ready check
app.add_systems(Update, (process_and_apply_actions_system, tick_attacks_system, handle_spawns_system, launch_ship_system).chain().after(update_current_turn_system).run_if(turn_is_ready));
app.add_systems(
Update,
(update_ships_system, handle_ship_arrivals_system, check_local_player_outcome, update_player_borders_system)
.chain()
// Runs after entire first chain (which includes handle_spawns_system)
.after(launch_ship_system)
.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 (must run before poll_turns_system)
app.add_systems(Update, generate_turns_system.before(poll_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)]
pub struct PreviousSpawnState {
pub 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 = time.epoch_millis() - (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 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));
}
}
/// 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::GameInitParamsBuilder::new(width, height, conquerable_tiles, NationId::ZERO, intent_receiver.rx.clone(), terrain_arc).build();
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::<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);
}
}
}
}
}