use crate::model::Snapshot;
use crate::theme;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph},
Frame,
};
use std::time::Instant;
const FALLBACK_FIELD_W: i32 = 60;
const FALLBACK_FIELD_H: i32 = 14;
const SHIP_X: i32 = 4;
const ENERGY_MAX: f32 = 100.0;
const LASER_COST: f32 = 10.0;
const BOMB_COST: f32 = 60.0;
const ENERGY_BASELINE_PER_SEC: f32 = 4.0;
const TOKENS_PER_ENERGY: f32 = 300.0;
const ENERGY_REFILL_MAX_PER_SEC: f32 = 40.0;
const SCROLL_MIN: f32 = 4.0; const SCROLL_MAX: f32 = 36.0; const SPAWN_MIN: f32 = 1.0; const SPAWN_MAX: f32 = 6.0; const BOSS_COST_STEP: f64 = 0.10;
const FRAME_BUDGET_US: u128 = 5_000;
const SLOW_FRAME_TRIP: u8 = 3;
pub(super) struct GameState {
field_w: i32,
field_h: i32,
ship_y: i32,
ship_target_y: i32,
obstacles: Vec<Obstacle>,
lasers: Vec<Laser>,
particles: Vec<Particle>,
energy: f32,
score: u64,
high_score: u64,
lives: u8,
last_frame: Instant,
frame: u64,
rng: u64,
scroll_accum: f32,
boss: Option<Boss>,
prev_cost: f64,
cur_scroll: f32,
cur_spawn: f32,
cur_refill: f32,
slow_frames: u8,
pub(super) disabled_self: bool,
over: bool,
pub(super) fullscreen: bool,
}
#[derive(Clone, Copy)]
struct Obstacle {
x: f32,
y: i32,
kind: ObstacleKind,
hp: i32,
}
#[derive(Clone, Copy)]
enum ObstacleKind { Small, Large, Danger }
#[derive(Clone, Copy)]
struct Laser {
x: f32,
prev_x: f32,
y: i32,
}
#[derive(Clone, Copy)]
struct Particle { x: i32, y: i32, ttl: u8, ch: char, color: Color }
#[derive(Clone, Copy)]
struct Boss {
x: f32,
y: f32,
hp: i32,
dy: f32, }
pub(super) enum KeyDispatch {
Handled,
CloseGame,
}
impl GameState {
pub(super) fn new() -> Self {
let now = Instant::now();
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0xC0FFEE_FEEDFACE);
Self {
field_w: FALLBACK_FIELD_W,
field_h: FALLBACK_FIELD_H,
ship_y: FALLBACK_FIELD_H / 2,
ship_target_y: FALLBACK_FIELD_H / 2,
obstacles: Vec::with_capacity(32),
lasers: Vec::with_capacity(8),
particles: Vec::with_capacity(32),
energy: ENERGY_MAX * 0.5,
score: 0,
high_score: 0,
lives: 3,
last_frame: now,
frame: 0,
rng: seed | 1,
scroll_accum: 0.0,
boss: None,
prev_cost: 0.0,
cur_scroll: SCROLL_MIN,
cur_spawn: SPAWN_MIN,
cur_refill: ENERGY_BASELINE_PER_SEC,
slow_frames: 0,
disabled_self: false,
over: false,
fullscreen: false,
}
}
fn reset(&mut self) {
self.obstacles.clear();
self.lasers.clear();
self.particles.clear();
self.energy = ENERGY_MAX * 0.5;
self.score = 0;
self.lives = 3;
self.ship_y = self.field_h / 2;
self.ship_target_y = self.field_h / 2;
self.boss = None;
self.scroll_accum = 0.0;
self.over = false;
self.last_frame = Instant::now();
}
fn rand(&mut self) -> u32 {
self.rng = self.rng
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(self.rng >> 33) as u32
}
fn rand_range(&mut self, lo: i32, hi_exclusive: i32) -> i32 {
if hi_exclusive <= lo { return lo; }
let span = (hi_exclusive - lo) as u32;
lo + (self.rand() % span) as i32
}
fn chance(&mut self, p: f32) -> bool {
let p = p.clamp(0.0, 1.0);
(self.rand() as f32 / u32::MAX as f32) < p
}
pub(super) fn handle_key(&mut self, key: KeyEvent) -> KeyDispatch {
if key.kind != KeyEventKind::Press { return KeyDispatch::Handled; }
if self.over {
return match key.code {
KeyCode::Char('`') | KeyCode::Esc => KeyDispatch::CloseGame,
_ => { self.reset(); KeyDispatch::Handled }
};
}
match key.code {
KeyCode::Char('`') => KeyDispatch::CloseGame,
KeyCode::Esc => {
if self.fullscreen {
self.fullscreen = false;
KeyDispatch::Handled
} else {
KeyDispatch::CloseGame
}
}
KeyCode::Char('Z') => {
self.fullscreen = !self.fullscreen;
KeyDispatch::Handled
}
KeyCode::Char(' ') => {
if self.energy >= LASER_COST && !self.disabled_self {
self.energy -= LASER_COST;
let lx0 = (SHIP_X + 2) as f32;
self.lasers.push(Laser {
x: lx0, prev_x: lx0, y: self.ship_y,
});
}
KeyDispatch::Handled
}
KeyCode::Char('b') => {
if self.energy >= BOMB_COST && !self.disabled_self {
self.energy -= BOMB_COST;
let kills = self.obstacles.len() as u64;
self.score = self.score.saturating_add(kills * 5);
for o in &self.obstacles {
self.particles.push(Particle {
x: o.x as i32, y: o.y, ttl: 6,
ch: '✺', color: theme::c_wait(),
});
}
self.obstacles.clear();
if let Some(b) = self.boss.as_mut() {
b.hp -= 20;
}
}
KeyDispatch::Handled
}
_ => KeyDispatch::Handled,
}
}
pub(super) fn tick(&mut self, snap: &Snapshot) {
if self.disabled_self || self.over { return; }
let max_y = (self.field_h - 1).max(0);
let max_x = (self.field_w - 1).max(0) as f32;
self.ship_y = self.ship_y.clamp(0, max_y);
self.ship_target_y = self.ship_target_y.clamp(0, max_y);
for o in &mut self.obstacles {
if o.y > max_y { o.y = max_y; }
if o.y < 0 { o.y = 0; }
if o.x > max_x { o.x = max_x; }
}
for l in &mut self.lasers {
if l.y > max_y { l.y = max_y; }
}
if let Some(b) = self.boss.as_mut() {
if b.x > max_x { b.x = max_x; }
let b_max_y = (self.field_h - 2).max(1) as f32;
if b.y > b_max_y { b.y = b_max_y; }
if b.y < 1.0 { b.y = 1.0; }
}
let frame_start = Instant::now();
let dt_s = frame_start
.saturating_duration_since(self.last_frame)
.as_micros() as f32 / 1_000_000.0;
let dt_s = dt_s.clamp(0.0, 0.5); self.last_frame = frame_start;
let a = &snap.aggregates;
let cpu_norm = (a.cpu as f32 / (snap.sys_cpus.max(1) as f32 * 100.0))
.clamp(0.0, 1.0);
let busy_load = (a.busy + a.subagents) as f32;
let tokens_per_sec = if !snap.history.tokens_rate.is_empty() {
let take = snap.history.tokens_rate.len().min(8);
let sum: f64 = snap.history.tokens_rate.iter().rev().take(take).sum();
(sum as f32 / take as f32) / 1.5
} else { 0.0 };
let target_scroll = lerp(SCROLL_MIN, SCROLL_MAX, cpu_norm);
let target_spawn = (SPAWN_MIN + busy_load * 0.5)
.clamp(SPAWN_MIN, SPAWN_MAX);
let target_refill = (ENERGY_BASELINE_PER_SEC + tokens_per_sec / TOKENS_PER_ENERGY)
.min(ENERGY_REFILL_MAX_PER_SEC);
let alpha = 0.3;
self.cur_scroll = self.cur_scroll + (target_scroll - self.cur_scroll) * alpha;
self.cur_spawn = self.cur_spawn + (target_spawn - self.cur_spawn) * alpha;
self.cur_refill = self.cur_refill + (target_refill - self.cur_refill) * alpha;
if self.boss.is_none() {
let prev_step = (self.prev_cost / BOSS_COST_STEP).floor() as i64;
let cur_step = (a.cost_usd / BOSS_COST_STEP).floor() as i64;
if cur_step > prev_step && a.cost_usd > 0.0 {
self.boss = Some(Boss {
x: (self.field_w - 4) as f32,
y: (self.field_h / 2) as f32,
hp: 40,
dy: 6.0,
});
}
}
self.prev_cost = a.cost_usd;
self.energy = (self.energy + self.cur_refill * dt_s).min(ENERGY_MAX);
self.scroll_accum += self.cur_scroll * dt_s;
let cells = self.scroll_accum.floor();
self.scroll_accum -= cells;
if cells > 0.0 {
for o in &mut self.obstacles { o.x -= cells; }
let fw = self.field_w;
if let Some(b) = self.boss.as_mut() {
if b.x > (fw - 10) as f32 { b.x -= cells * 0.5; }
}
}
for l in &mut self.lasers {
l.prev_x = l.x;
l.x += 40.0 * dt_s;
}
let fh = self.field_h;
if let Some(b) = self.boss.as_mut() {
b.y += b.dy * dt_s;
if b.y < 1.0 { b.y = 1.0; b.dy = b.dy.abs(); }
let max_y = (fh - 2).max(1) as f32;
if b.y > max_y { b.y = max_y; b.dy = -b.dy.abs(); }
}
let before = self.obstacles.len();
self.obstacles.retain(|o| o.x > -2.0);
self.score = self.score.saturating_add((before - self.obstacles.len()) as u64);
let fw = self.field_w;
self.lasers.retain(|l| l.x < (fw + 2) as f32);
for p in &mut self.particles { p.ttl = p.ttl.saturating_sub(1); }
self.particles.retain(|p| p.ttl > 0);
let p_spawn = self.cur_spawn * dt_s;
if self.chance(p_spawn) {
let y = self.rand_range(0, self.field_h.max(1));
let n_agents = snap.agents.len().max(1);
let n_danger = snap.agents.iter().filter(|a| a.dangerous).count();
let danger_p = (n_danger as f32 / n_agents as f32).clamp(0.0, 0.5);
let kind = if self.chance(danger_p) {
ObstacleKind::Danger
} else if self.chance(0.25) {
ObstacleKind::Large
} else {
ObstacleKind::Small
};
let hp = match kind {
ObstacleKind::Small => 1,
ObstacleKind::Large => 2,
ObstacleKind::Danger => 1,
};
self.obstacles.push(Obstacle {
x: (self.field_w - 1) as f32, y, kind, hp,
});
}
let mut consumed: Vec<usize> = Vec::new();
let mut new_particles: Vec<Particle> = Vec::new();
for li in 0..self.lasers.len() {
let l_lo = self.lasers[li].prev_x.min(self.lasers[li].x);
let l_hi = self.lasers[li].prev_x.max(self.lasers[li].x);
let lx_now = self.lasers[li].x.round() as i32;
let ly = self.lasers[li].y;
let mut hit_something = false;
for o in self.obstacles.iter_mut() {
if o.y == ly && o.x >= l_lo - 0.5 && o.x <= l_hi + 0.5 {
o.hp -= 1;
hit_something = true;
if o.hp <= 0 {
new_particles.push(Particle {
x: o.x.round() as i32, y: o.y, ttl: 4, ch: '✦',
color: theme::c_busy(),
});
}
break;
}
}
if let Some(b) = self.boss.as_mut() {
if (ly - b.y.round() as i32).abs() <= 1
&& b.x >= l_lo - 1.5 && b.x <= l_hi + 1.5
{
b.hp -= 2;
hit_something = true;
new_particles.push(Particle {
x: lx_now, y: ly, ttl: 3, ch: '*',
color: theme::c_wait(),
});
}
}
if hit_something { consumed.push(li); }
}
self.particles.extend(new_particles);
consumed.sort_unstable();
consumed.dedup();
for &i in consumed.iter().rev() {
if i < self.lasers.len() { self.lasers.swap_remove(i); }
}
let kills = self.obstacles.iter().filter(|o| o.hp <= 0).count() as u64;
self.obstacles.retain(|o| o.hp > 0);
self.score = self.score.saturating_add(kills * 5);
let mut boss_killed_at: Option<(i32, i32)> = None;
if let Some(b) = self.boss {
if b.hp <= 0 {
boss_killed_at = Some((b.x.round() as i32, b.y.round() as i32));
}
}
if let Some((bx, by)) = boss_killed_at {
self.score = self.score.saturating_add(50);
for _ in 0..14 {
let dx = self.rand_range(-3, 4);
let dy = self.rand_range(-2, 3);
self.particles.push(Particle {
x: bx + dx, y: by + dy, ttl: 8,
ch: '✺', color: theme::c_wait(),
});
}
self.boss = None;
}
let lookahead = (self.field_w - SHIP_X).max(4).min(20);
let mut best_row = self.ship_y;
let mut best_cost = i32::MAX;
let mut total_threats_seen = 0;
for cand in 0..self.field_h {
let mut threat = 0;
for o in &self.obstacles {
let dx = o.x.round() as i32 - SHIP_X;
if dx <= 0 || dx > lookahead { continue; }
let dy = (o.y - cand).abs();
if dy > 1 { continue; }
let weight = match o.kind {
ObstacleKind::Danger => 12,
ObstacleKind::Large => 5,
ObstacleKind::Small => 2,
};
let row_factor = if dy == 0 { 2 } else { 1 };
threat += weight * row_factor * (lookahead - dx + 1);
}
let bias = (cand - self.ship_y).abs();
let total = threat + bias;
if total < best_cost {
best_cost = total;
best_row = cand;
}
total_threats_seen += threat;
}
self.frame = self.frame.wrapping_add(1);
if total_threats_seen == 0 {
let t = self.frame as f32 * 0.1;
let amp = (self.field_h as f32 / 3.0).max(2.0);
best_row = (self.field_h as f32 / 2.0 + amp * t.sin()) as i32;
best_row = best_row.clamp(0, (self.field_h - 1).max(0));
}
self.ship_target_y = best_row;
if self.ship_y < self.ship_target_y { self.ship_y += 1; }
else if self.ship_y > self.ship_target_y { self.ship_y -= 1; }
let mut hit = false;
self.obstacles.retain(|o| {
let ox = o.x.round() as i32;
if ox == SHIP_X && o.y == self.ship_y {
hit = true;
false
} else { true }
});
if hit {
self.lives = self.lives.saturating_sub(1);
for _ in 0..6 {
let dx = self.rand_range(-2, 3);
let dy = self.rand_range(-1, 2);
self.particles.push(Particle {
x: SHIP_X + dx, y: self.ship_y + dy, ttl: 6,
ch: '*', color: theme::c_wait(),
});
}
if self.lives == 0 {
if self.score > self.high_score { self.high_score = self.score; }
self.over = true;
}
}
let frame_us = frame_start.elapsed().as_micros();
if frame_us > FRAME_BUDGET_US {
self.slow_frames = self.slow_frames.saturating_add(1);
if self.slow_frames >= SLOW_FRAME_TRIP {
self.disabled_self = true;
}
} else {
self.slow_frames = 0;
}
}
pub(super) fn draw(&mut self, f: &mut Frame, area: Rect) {
let title = if self.disabled_self {
" agtop:dodge — disabled (frame budget exceeded) ".to_string()
} else if self.over {
format!(" agtop:dodge — GAME OVER · score {} · any key restarts ", self.score)
} else {
format!(" agtop:dodge — score {} high {} ♥{} ", self.score, self.high_score, self.lives)
};
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::border()))
.title(Span::styled(title,
Style::default().fg(theme::fg()).add_modifier(Modifier::BOLD)));
let inner = block.inner(area);
f.render_widget(block, area);
if self.disabled_self {
let lines = vec![
Line::from(Span::styled(" Self-disabled to keep agtop responsive.",
Style::default().fg(theme::fg_dim()))),
Line::from(Span::raw("")),
Line::from(Span::styled(" Press ` to restore the sessions panel.",
Style::default().fg(theme::fg_dim()))),
];
f.render_widget(Paragraph::new(lines), inner);
return;
}
if inner.width < 30 || inner.height < 6 {
let p = Paragraph::new(format!(
" game needs ≥30×6 cells (have {}×{}).",
inner.width, inner.height
)).style(Style::default().fg(theme::fg_dim()));
f.render_widget(p, inner);
return;
}
let w = inner.width as usize;
let h = inner.height as usize;
let play_h = h.saturating_sub(1); if play_h == 0 { return; }
self.field_w = w as i32;
self.field_h = play_h as i32;
let mut grid: Vec<Vec<Cell>> = vec![
vec![Cell::blank(); w];
play_h
];
for y in 0..play_h {
let mut x = ((y as u32).wrapping_mul(2654435761) as usize) % 17;
while x < w {
grid[y][x] = Cell { ch: '·', color: theme::fg_dim(), bold: false };
x += 19;
}
}
for p in &self.particles {
put(&mut grid, p.x, p.y, p.ch, p.color, true, w, play_h);
}
for o in &self.obstacles {
let (ch, color) = match o.kind {
ObstacleKind::Small => ('•', theme::fg()),
ObstacleKind::Large => ('▓', theme::fg()),
ObstacleKind::Danger => ('!', theme::c_wait()),
};
put(&mut grid, o.x.round() as i32, o.y, ch, color, true, w, play_h);
}
if let Some(b) = &self.boss {
let bx = b.x.round() as i32;
let by = b.y.round() as i32;
for dy in -1..=1 {
for dx in -1..=1 {
let ch = if dx == 0 && dy == 0 { '◆' } else { '▒' };
put(&mut grid, bx + dx, by + dy, ch, theme::c_done(), true, w, play_h);
}
}
}
for l in &self.lasers {
let from = l.prev_x.round() as i32;
let to = l.x.round() as i32;
let (lo, hi) = if from <= to { (from, to) } else { (to, from) };
for x in lo..=hi {
put(&mut grid, x, l.y, '─', theme::c_busy(), true, w, play_h);
}
}
let sy = self.ship_y;
put(&mut grid, SHIP_X - 1, sy, '>', theme::c_active(), true, w, play_h);
put(&mut grid, SHIP_X, sy, '=', theme::c_active(), true, w, play_h);
put(&mut grid, SHIP_X + 1, sy, '>', theme::c_active(), true, w, play_h);
if self.over {
let mid = play_h / 2;
let msg = format!("GAME OVER · score {} · press any key", self.score);
let pad = w.saturating_sub(msg.len()) / 2;
if mid < grid.len() {
for c in &mut grid[mid] { *c = Cell::blank(); }
for (i, ch) in msg.chars().enumerate() {
let x = pad + i;
if x < w {
grid[mid][x] = Cell { ch, color: theme::c_wait(), bold: true };
}
}
}
}
let mut lines: Vec<Line> = Vec::with_capacity(h);
for row in &grid {
lines.push(coalesce_row(row));
}
lines.push(self.hud_line(w));
f.render_widget(Paragraph::new(lines), inner);
}
fn hud_line(&self, w: usize) -> Line<'static> {
let bar_w = (w / 3).clamp(8, 32);
let filled = ((self.energy / ENERGY_MAX) * bar_w as f32).round() as usize;
let filled = filled.min(bar_w);
let bar_color = if self.energy >= BOMB_COST { theme::c_busy() }
else if self.energy >= LASER_COST { theme::c_active() }
else { theme::c_wait() };
let mut bar = String::with_capacity(bar_w * 3);
for _ in 0..filled { bar.push('█'); }
for _ in filled..bar_w { bar.push('░'); }
Line::from(vec![
Span::styled(" energy ", Style::default().fg(theme::fg_dim())),
Span::styled(bar, Style::default().fg(bar_color)),
Span::styled(format!(" {:>3.0}/{:.0} ", self.energy, ENERGY_MAX),
Style::default().fg(theme::fg_dim())),
Span::styled("SPC", Style::default().fg(theme::fg()).add_modifier(Modifier::BOLD)),
Span::styled(" fire ", Style::default().fg(theme::fg_dim())),
Span::styled("b", Style::default().fg(theme::fg()).add_modifier(Modifier::BOLD)),
Span::styled(" bomb ", Style::default().fg(theme::fg_dim())),
Span::styled("Z", Style::default().fg(theme::fg()).add_modifier(Modifier::BOLD)),
Span::styled(if self.fullscreen { " window " } else { " zoom " },
Style::default().fg(theme::fg_dim())),
Span::styled("`", Style::default().fg(theme::fg()).add_modifier(Modifier::BOLD)),
Span::styled(" exit", Style::default().fg(theme::fg_dim())),
])
}
}
#[derive(Clone, Copy)]
struct Cell {
ch: char,
color: Color,
bold: bool,
}
impl Cell {
fn blank() -> Self { Self { ch: ' ', color: Color::Reset, bold: false } }
}
fn put(grid: &mut [Vec<Cell>], x: i32, y: i32, ch: char, color: Color, bold: bool, w: usize, h: usize) {
if x < 0 || y < 0 { return; }
let (xu, yu) = (x as usize, y as usize);
if xu >= w || yu >= h { return; }
grid[yu][xu] = Cell { ch, color, bold };
}
fn coalesce_row(row: &[Cell]) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
if row.is_empty() { return Line::from(spans); }
let mut buf = String::new();
let mut cur = row[0];
buf.push(cur.ch);
for c in &row[1..] {
if c.color == cur.color && c.bold == cur.bold {
buf.push(c.ch);
} else {
spans.push(span_of(std::mem::take(&mut buf), cur));
buf.push(c.ch);
cur = *c;
}
}
spans.push(span_of(buf, cur));
Line::from(spans)
}
fn span_of(text: String, cell: Cell) -> Span<'static> {
let mut st = Style::default().fg(cell.color);
if cell.bold { st = st.add_modifier(Modifier::BOLD); }
Span::styled(text, st)
}
fn lerp(a: f32, b: f32, t: f32) -> f32 {
a + (b - a) * t.clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Snapshot;
use crossterm::event::{KeyEvent, KeyModifiers};
fn key(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
}
#[test]
fn autopilot_dodges_same_row_obstacle() {
let mut g = GameState::new();
g.ship_y = 5;
g.ship_target_y = 5;
g.obstacles.push(Obstacle {
x: (SHIP_X + 4) as f32,
y: 5,
kind: ObstacleKind::Small,
hp: 1,
});
let snap = Snapshot::default();
g.tick(&snap);
assert_ne!(g.ship_target_y, 5,
"autopilot should pick a different row than the one with the obstacle");
}
#[test]
fn danger_obstacle_outweighs_two_small_ones() {
let mut g = GameState::new();
g.ship_y = 8;
g.ship_target_y = 8;
g.obstacles.push(Obstacle {
x: (SHIP_X + 5) as f32, y: 8,
kind: ObstacleKind::Danger, hp: 1,
});
g.obstacles.push(Obstacle {
x: (SHIP_X + 5) as f32, y: 9,
kind: ObstacleKind::Small, hp: 1,
});
g.obstacles.push(Obstacle {
x: (SHIP_X + 6) as f32, y: 9,
kind: ObstacleKind::Small, hp: 1,
});
let snap = Snapshot::default();
g.tick(&snap);
assert_ne!(g.ship_target_y, 8);
}
#[test]
fn fire_consumes_energy_and_spawns_laser() {
let mut g = GameState::new();
let start = g.energy;
let _ = g.handle_key(key(' '));
assert!(g.energy < start, "energy should drop after firing");
assert_eq!(g.lasers.len(), 1);
}
#[test]
fn fire_blocked_when_energy_low() {
let mut g = GameState::new();
g.energy = LASER_COST - 0.1;
let _ = g.handle_key(key(' '));
assert_eq!(g.lasers.len(), 0,
"laser should not spawn when energy is below cost");
}
#[test]
fn bomb_clears_obstacles() {
let mut g = GameState::new();
g.energy = BOMB_COST;
for i in 0..3 {
g.obstacles.push(Obstacle {
x: (SHIP_X + 5 + i) as f32, y: 4,
kind: ObstacleKind::Small, hp: 1,
});
}
let _ = g.handle_key(key('b'));
assert!(g.obstacles.is_empty(), "bomb should clear every obstacle");
assert!(g.score >= 15, "score should reward each cleared obstacle");
}
#[test]
fn backtick_requests_close() {
let mut g = GameState::new();
match g.handle_key(key('`')) {
KeyDispatch::CloseGame => {}
_ => panic!("backtick should request CloseGame"),
}
}
}