Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,12 @@ rand = "0.8"
tokio = { version = "1.35", features = ["full"] }
warp = { version = "0.3", features = ["websocket"] }
futures = "0.3"


[lib]
name = "grid_crawler_wsl"
path = "src/lib.rs"

[[bin]]
name = "grid_crawler_wsl"
path = "src/main.rs"
6 changes: 3 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
- [x] Support dynamic seed and map size selection
- [x] Add mobile WebSocket interface and browser client
- [x] Add firing actions and weapon mechanics
- [ ] Implement mock serial fallback for hardware development
- [ ] Improve in-game HUD and action feedback
- [ ] Add integration tests for movement, action points, and pickups
- [x] Implement mock serial fallback for hardware development
- [x] Improve in-game HUD and action feedback
- [x] Add integration tests for movement, action points, and pickups

## Medium-term goals

Expand Down
3 changes: 3 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pub mod net;
pub mod serial_daemon;
pub mod state;
27 changes: 17 additions & 10 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
mod net;
mod serial_daemon;
mod state;

use crossterm::{
event::{self, Event, KeyCode},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use grid_crawler_wsl::state::{
AppPhase, GameState, fire_at_direction, initialize_state, move_player, regenerate_map,
spawn_drops,
};
use rand::random;
use ratatui::{
Frame, Terminal,
Expand All @@ -16,10 +16,6 @@ use ratatui::{
text::{Line, Span},
widgets::{Block, Borders, Gauge, Paragraph},
};
use state::{
AppPhase, GameState, fire_at_direction, initialize_state, move_player, regenerate_map,
spawn_drops,
};
use std::env;
use std::io;
use std::sync::Arc;
Expand All @@ -37,8 +33,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}

let game_state = initialize_state();
let _tx_port = serial_daemon::init_hardware_bridge(Arc::clone(&game_state), "/dev/ttyACM0");
net::start_ws_server(Arc::clone(&game_state), "127.0.0.1:9001");
let _tx_port = grid_crawler_wsl::serial_daemon::init_hardware_bridge(
Arc::clone(&game_state),
"/dev/ttyACM0",
);
grid_crawler_wsl::net::start_ws_server(Arc::clone(&game_state), "127.0.0.1:9001");

enable_raw_mode()?;
let mut stdout = io::stdout();
Expand Down Expand Up @@ -195,6 +194,7 @@ fn draw_combat_ui(f: &mut Frame, state: &GameState, aiming: bool) {
Constraint::Length(3), // HP
Constraint::Length(3), // AR
Constraint::Length(3), // AP
Constraint::Length(3), // SYSTEM LOG
Constraint::Min(0),
]
.as_ref(),
Expand Down Expand Up @@ -301,6 +301,13 @@ fn draw_combat_ui(f: &mut Frame, state: &GameState, aiming: bool) {
)
.percent(ap_percent.min(100));
f.render_widget(ap_gauge, chunks[4]);

// --- System Log (Feedback) ---
let log_widget = Paragraph::new(state.feedback.clone())
.block(Block::default().borders(Borders::ALL).title(" SYSTEM LOG "))
.alignment(Alignment::Center)
.style(Style::default().fg(Color::LightGreen));
f.render_widget(log_widget, chunks[5]);
}

fn draw_game_over(f: &mut Frame) {
Expand Down
1 change: 1 addition & 0 deletions src/serial_daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ pub fn init_hardware_bridge(state: SharedState, port_name: &str) -> Box<dyn Writ
let mut lock = sim_state.lock().unwrap();
// regen small AP over time
lock.stats.ap = (lock.stats.ap + 1).min(12);
lock.feedback = "Hardware mock: Regenerated 1 AP".to_string();
Comment on lines 57 to +58

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The mock serial daemon background thread runs every second and sets lock.feedback = "Hardware mock: Regenerated 1 AP". This happens even if the player's AP is already at the maximum of 12 (so no AP is actually regenerated). This constantly overwrites any other active feedback in the system log (like "Moved successfully", "Target destroyed!", etc.) with a misleading message, making the system log feature almost useless when using the mock fallback.

                    if lock.stats.ap < 12 {
                        lock.stats.ap += 1;
                        lock.feedback = "Hardware mock: Regenerated 1 AP".to_string();
                    }

}
});
Box::new(sink)
Expand Down
21 changes: 21 additions & 0 deletions src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub struct GameState {
pub height: usize,
pub seed: u64,
pub player_idx: usize,
pub feedback: String,
}

pub type SharedState = Arc<Mutex<GameState>>;
Expand Down Expand Up @@ -136,6 +137,7 @@ pub fn initialize_state() -> SharedState {
height,
seed,
player_idx,
feedback: "Neural link established. Awaiting input.".to_string(),
};

Arc::new(Mutex::new(initial_state))
Expand All @@ -150,6 +152,7 @@ pub fn regenerate_map(state: &mut GameState, seed: u64, size: usize) {
state.height = size;
state.seed = seed;
state.player_idx = new_player_idx;
state.feedback = "Map regenerated.".to_string();
}

/// Move the player by dx,dy if there is enough AP and no wall. Returns true if moved.
Expand All @@ -161,13 +164,16 @@ pub fn move_player(state: &mut GameState, dx: isize, dy: isize) -> bool {
let nx = x as isize + dx;
let ny = y as isize + dy;
Comment on lines 164 to 165

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If dx and dy are both 0, move_player will still execute, decrementing the player's AP by 1 and performing a redundant self-assignment of the player tile without actually moving. Adding a guard to prevent zero-delta moves avoids wasting AP.

    if dx == 0 && dy == 0 {
        return false;
    }
    let nx = x as isize + dx;
    let ny = y as isize + dy;

if nx < 0 || ny < 0 || nx >= state.width as isize || ny >= state.height as isize {
state.feedback = "Cannot move outside map boundaries.".to_string();
return false;
}
let nidx = (ny as usize) * state.width + (nx as usize);
if state.map_matrix[nidx] == Tile::Wall as u8 || state.map_matrix[nidx] == Tile::Enemy as u8 {
state.feedback = "Path blocked.".to_string();
return false;
}
if state.stats.ap == 0 {
state.feedback = "Not enough AP to move.".to_string();
return false;
}
state.stats.ap = state.stats.ap.saturating_sub(1);
Expand All @@ -176,13 +182,17 @@ pub fn move_player(state: &mut GameState, dx: isize, dy: isize) -> bool {
state.map_matrix[nidx] = Tile::Player as u8;
state.player_idx = nidx;
let _ = consume_tile_effect(state, target_tile);
if target_tile == Tile::Empty as u8 {
state.feedback = "Moved successfully.".to_string();
}
true
}

/// Fire into an adjacent tile, consuming AP and resolving any tile effect.
pub fn fire_at_direction(state: &mut GameState, dx: isize, dy: isize) -> bool {
let idx = state.player_idx;
if state.stats.ap < 2 {
Comment on lines 193 to 194

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If dx and dy are both 0, fire_at_direction will enter an infinite loop because cx and cy will never change, causing the application to hang. Adding a guard to prevent zero-delta firing avoids this critical failure.

    if dx == 0 && dy == 0 {
        return false;
    }
    let idx = state.player_idx;
    if state.stats.ap < 2 {

state.feedback = "Not enough AP to fire (needs 2).".to_string();
return false;
}
state.stats.ap = state.stats.ap.saturating_sub(2);
Expand All @@ -202,36 +212,44 @@ pub fn fire_at_direction(state: &mut GameState, dx: isize, dy: isize) -> bool {
}
if target == Tile::Enemy as u8 {
state.map_matrix[nidx] = Tile::Wreck as u8;
state.feedback = "Target destroyed!".to_string();
return true;
} else if target != Tile::Empty as u8 {
state.map_matrix[nidx] = Tile::Empty as u8;
state.feedback = "Obstacle cleared.".to_string();
return true;
}
}
state.feedback = "Missed.".to_string();
false
}

fn consume_tile_effect(state: &mut GameState, tile: u8) -> bool {
match tile {
x if x == Tile::Health as u8 => {
state.stats.health = state.stats.health.saturating_add(20).min(100);
state.feedback = "Picked up Health: +20 HP".to_string();
true
}
x if x == Tile::Smoke as u8 => {
state.stats.active_item = 1;
state.stats.item_charges = 1;
state.feedback = "Acquired Smoke charge.".to_string();
true
}
x if x == Tile::Resource as u8 => {
state.stats.ap = state.stats.ap.saturating_add(3).min(12);
state.feedback = "Acquired Resource: +3 AP".to_string();
true
}
x if x == Tile::Mine as u8 => {
state.stats.health = state.stats.health.saturating_sub(15);
state.feedback = "Hit a Mine! -15 HP".to_string();
true
}
x if x == Tile::Wreck as u8 => {
state.stats.armor = state.stats.armor.saturating_sub(10);
state.feedback = "Scraped wreckage: -10 Armor".to_string();
true
}
_ => false,
Expand Down Expand Up @@ -307,6 +325,7 @@ mod tests {
height: 1,
seed: 1,
player_idx: 0,
feedback: "".to_string(),
};
let applied = consume_tile_effect(&mut gs, Tile::Health as u8);
gs.map_matrix[0] = Tile::Empty as u8;
Expand All @@ -333,6 +352,7 @@ mod tests {
height: 1,
seed: 1,
player_idx: 0,
feedback: "".to_string(),
};
// try to move right into wall (should fail)
assert!(!move_player(&mut gs, 1, 0));
Expand Down Expand Up @@ -372,6 +392,7 @@ mod fire_tests {
height: 1,
seed: 1,
player_idx: 0,
feedback: "".to_string(),
};

// Fire right
Expand Down
90 changes: 90 additions & 0 deletions tests/integration_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
use grid_crawler_wsl::state::{
AppPhase, GameState, PlayerStats, Tile, fire_at_direction, move_player,
};

#[test]
fn test_player_sequence_move_pickup_fire() {
let mut gs = GameState {
phase: AppPhase::Playing,
stats: PlayerStats {
health: 50,
armor: 10,
ap: 4,
is_supercharging: false,
has_shield: false,
active_item: 0,
item_charges: 0,
},
map_matrix: vec![
Tile::Player as u8,
Tile::Health as u8,
Tile::Empty as u8,
Tile::Enemy as u8,
],
width: 4,
height: 1,
seed: 1,
player_idx: 0,
feedback: "".to_string(),
};

// Step 1: Move right onto Health
assert!(move_player(&mut gs, 1, 0));
assert_eq!(gs.stats.ap, 3); // Cost 1 AP
assert_eq!(gs.stats.health, 70); // Health increased
assert_eq!(gs.player_idx, 1);
assert_eq!(gs.map_matrix[0], Tile::Empty as u8); // Old position empty
assert_eq!(gs.map_matrix[1], Tile::Player as u8); // New position player

// Step 2: Fire right
assert!(fire_at_direction(&mut gs, 1, 0));
assert_eq!(gs.stats.ap, 1); // Cost 2 AP
assert_eq!(gs.map_matrix[3], Tile::Wreck as u8); // Enemy turns to wreck

// Step 3: Try to fire again (not enough AP)
assert!(!fire_at_direction(&mut gs, 1, 0));
assert_eq!(gs.stats.ap, 1); // AP unchanged
}

#[test]
fn test_player_sequence_move_obstacle_mine() {
let mut gs = GameState {
phase: AppPhase::Playing,
stats: PlayerStats {
health: 100,
armor: 50,
ap: 4,
is_supercharging: false,
has_shield: false,
active_item: 0,
item_charges: 0,
},
map_matrix: vec![
Tile::Player as u8,
Tile::Wall as u8,
Tile::Empty as u8,
Tile::Mine as u8,
],
width: 2,
height: 2,
seed: 1,
player_idx: 0,
feedback: "".to_string(),
};

// Try moving right into wall
assert!(!move_player(&mut gs, 1, 0));
assert_eq!(gs.stats.ap, 4); // AP unchanged
assert_eq!(gs.player_idx, 0); // Position unchanged

// Move down into empty space
assert!(move_player(&mut gs, 0, 1));
assert_eq!(gs.stats.ap, 3); // Cost 1 AP
assert_eq!(gs.player_idx, 2);

// Move right into mine
assert!(move_player(&mut gs, 1, 0));
assert_eq!(gs.stats.ap, 2); // Cost 1 AP
assert_eq!(gs.player_idx, 3);
assert_eq!(gs.stats.health, 85); // Hit mine
}
Loading