From e3242583b686e9efab3270121919f7bc369eb995 Mon Sep 17 00:00:00 2001 From: markmental Date: Tue, 10 Feb 2026 17:21:40 -0500 Subject: [PATCH] Manual Team Selection + CPU Auto-Fill (TUI + Quick Mode) --- DEVLOG.md | 17 ++++ src/app.rs | 262 ++++++++++++++++++++++++++++++++++++++++++++---- src/main.rs | 20 ++-- src/ui/mod.rs | 8 +- src/ui/modal.rs | 102 ++++++++++++++++++- 5 files changed, 374 insertions(+), 35 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index 155f2ed..9b0e3e2 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -37,3 +37,20 @@ - No external data files are required at runtime. - Team data is fully embedded in the binary. - CSV export format is mode-specific and generated with std I/O. + +## 2026-02-10 - Team selection controls (manual + CPU auto-fill) + +### Scope completed +- Added interactive create modal in TUI for team slot configuration. +- Added per-slot selection mode: + - `Manual` team selection + - `CPU auto-fill` +- Kept deterministic behavior by resolving CPU team picks with the instance seed. +- Added keyboard controls for modal flow: + - `m` set manual, `p` set CPU + - `[` / `]` or left/right to cycle manual teams + - up/down to switch slot + - Enter to create, Esc to cancel +- Updated `quick` command to support optional `--home` / `--away`: + - If missing, CPU auto-fills from remaining teams. + - Same seed yields the same auto-filled teams and outcomes. diff --git a/src/app.rs b/src/app.rs index 6d5e64e..1c86241 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::fs::File; use std::io::{self, Write}; use std::time::{Duration, Instant}; @@ -52,10 +53,50 @@ pub struct App { pub instances: Vec, pub selected: usize, pub show_detail: bool, + pub create_draft: Option, pub status_line: String, next_id: usize, } +#[derive(Debug, Clone)] +pub struct TeamSlotDraft { + pub is_cpu: bool, + pub team_idx: usize, +} + +#[derive(Debug, Clone)] +pub struct CreateDraft { + pub mode: SimulationType, + pub slots: Vec, + pub selected_slot: usize, +} + +impl CreateDraft { + pub fn new(mode: SimulationType) -> Self { + let count = slot_count_for_mode(mode); + let mut slots = Vec::with_capacity(count); + for i in 0..count { + slots.push(TeamSlotDraft { + is_cpu: true, + team_idx: i % TEAMS.len(), + }); + } + Self { + mode, + slots, + selected_slot: 0, + } + } + + pub fn mode_label(&self) -> &'static str { + match self.mode { + SimulationType::Single => "Single Match", + SimulationType::League4 => "4-Team League", + SimulationType::Knockout4 => "4-Team Knockout", + } + } +} + impl App { pub fn new(base_seed: u64, speed: Speed) -> Self { Self { @@ -64,24 +105,113 @@ impl App { instances: Vec::with_capacity(MAX_INSTANCES), selected: 0, show_detail: false, + create_draft: None, status_line: format!("Ready. Seed={base_seed}, Speed={}", speed.label()), next_id: 0, } } - pub fn create_instance(&mut self, sim_type: SimulationType) { + pub fn open_create_draft(&mut self, sim_type: SimulationType) { + self.create_draft = Some(CreateDraft::new(sim_type)); + self.status_line = format!("Create modal: {}", sim_type.as_str()); + } + + pub fn cancel_create_draft(&mut self) { + self.create_draft = None; + self.status_line = "Create canceled".to_string(); + } + + pub fn next_instance_seed_preview(&self) -> u64 { + derive_seed(self.base_seed, self.next_id as u64 + 1) + } + + pub fn draft_select_prev_slot(&mut self) { + let Some(draft) = self.create_draft.as_mut() else { + return; + }; + if draft.selected_slot == 0 { + draft.selected_slot = draft.slots.len().saturating_sub(1); + } else { + draft.selected_slot -= 1; + } + } + + pub fn draft_select_next_slot(&mut self) { + let Some(draft) = self.create_draft.as_mut() else { + return; + }; + if draft.slots.is_empty() { + return; + } + draft.selected_slot = (draft.selected_slot + 1) % draft.slots.len(); + } + + pub fn draft_set_selected_manual(&mut self) { + let Some(draft) = self.create_draft.as_mut() else { + return; + }; + if let Some(slot) = draft.slots.get_mut(draft.selected_slot) { + slot.is_cpu = false; + } + } + + pub fn draft_set_selected_cpu(&mut self) { + let Some(draft) = self.create_draft.as_mut() else { + return; + }; + if let Some(slot) = draft.slots.get_mut(draft.selected_slot) { + slot.is_cpu = true; + } + } + + pub fn draft_cycle_selected_team(&mut self, delta: i32) { + let Some(draft) = self.create_draft.as_mut() else { + return; + }; + let Some(slot) = draft.slots.get_mut(draft.selected_slot) else { + return; + }; + if slot.is_cpu { + return; + } + + let len = TEAMS.len() as i32; + let mut idx = slot.team_idx as i32 + delta; + if idx < 0 { + idx = len - 1; + } else if idx >= len { + idx = 0; + } + slot.team_idx = idx as usize; + } + + pub fn confirm_create_draft(&mut self) { if self.instances.len() >= MAX_INSTANCES { self.status_line = format!("Instance limit reached ({MAX_INSTANCES})"); return; } + + let Some(draft) = self.create_draft.clone() else { + return; + }; + let id = self.next_id; let seed = derive_seed(self.base_seed, id as u64 + 1); - let teams = self.random_teams_for_mode(sim_type, seed); - let instance = SimulationInstance::new(id, sim_type, teams, seed); + + let teams = match resolve_teams_from_slots(&draft.slots, seed) { + Ok(v) => v, + Err(e) => { + self.status_line = e; + return; + } + }; + + let instance = SimulationInstance::new(id, draft.mode, teams, seed); self.instances.push(instance); self.selected = self.instances.len().saturating_sub(1); self.next_id += 1; self.status_line = format!("Created sim-{id}"); + self.create_draft = None; } pub fn start_selected(&mut self) { @@ -146,14 +276,6 @@ impl App { self.status_line = format!("Speed set to {}", self.speed.label()); } - fn random_teams_for_mode(&self, sim_type: SimulationType, seed: u64) -> Vec { - let count = match sim_type { - SimulationType::Single => 2, - SimulationType::League4 | SimulationType::Knockout4 => 4, - }; - unique_random_teams(count, seed) - } - pub fn tick(&mut self) { let frames = self.speed.frames_per_tick(); for inst in &mut self.instances { @@ -205,13 +327,34 @@ pub fn run_tui(mut app: App) -> io::Result<()> { if event::poll(timeout)? { if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { + if app.create_draft.is_some() { + match key.code { + KeyCode::Esc => app.cancel_create_draft(), + KeyCode::Enter => app.confirm_create_draft(), + KeyCode::Up | KeyCode::Char('k') | KeyCode::Tab => { + app.draft_select_prev_slot() + } + KeyCode::Down | KeyCode::Char('j') => app.draft_select_next_slot(), + KeyCode::Left | KeyCode::Char('h') | KeyCode::Char('[') => { + app.draft_cycle_selected_team(-1) + } + KeyCode::Right | KeyCode::Char('l') | KeyCode::Char(']') => { + app.draft_cycle_selected_team(1) + } + KeyCode::Char('m') => app.draft_set_selected_manual(), + KeyCode::Char('p') => app.draft_set_selected_cpu(), + _ => {} + } + continue; + } + match key.code { KeyCode::Char('q') => running = false, KeyCode::Up | KeyCode::Char('k') => app.select_prev(), KeyCode::Down | KeyCode::Char('j') => app.select_next(), - KeyCode::Char('n') => app.create_instance(SimulationType::Single), - KeyCode::Char('l') => app.create_instance(SimulationType::League4), - KeyCode::Char('o') => app.create_instance(SimulationType::Knockout4), + KeyCode::Char('n') => app.open_create_draft(SimulationType::Single), + KeyCode::Char('l') => app.open_create_draft(SimulationType::League4), + KeyCode::Char('o') => app.open_create_draft(SimulationType::Knockout4), KeyCode::Char('s') => app.start_selected(), KeyCode::Char('c') => app.clone_selected(), KeyCode::Char('d') => app.delete_selected(), @@ -239,13 +382,88 @@ pub fn run_tui(mut app: App) -> io::Result<()> { Ok(()) } -fn unique_random_teams(count: usize, seed: u64) -> Vec { - let mut rng = Rng::new(seed); - let mut pool: Vec<&str> = TEAMS.to_vec(); - let mut picked = Vec::with_capacity(count); - while picked.len() < count && !pool.is_empty() { - let i = rng.range_usize(pool.len()); - picked.push(pool.remove(i).to_string()); +fn slot_count_for_mode(sim_type: SimulationType) -> usize { + match sim_type { + SimulationType::Single => 2, + SimulationType::League4 | SimulationType::Knockout4 => 4, } - picked +} + +fn resolve_teams_from_slots(slots: &[TeamSlotDraft], seed: u64) -> Result, String> { + let mut seen = HashSet::new(); + let mut cpu_count = 0usize; + + for slot in slots { + if slot.is_cpu { + cpu_count += 1; + continue; + } + let team = TEAMS + .get(slot.team_idx) + .ok_or_else(|| "Manual team selection is out of range".to_string())?; + if !seen.insert(*team) { + return Err(format!("Duplicate manual team: {team}")); + } + } + + let mut remaining: Vec<&str> = TEAMS + .iter() + .copied() + .filter(|team| !seen.contains(team)) + .collect(); + + if remaining.len() < cpu_count { + return Err("Not enough teams left for CPU auto-fill".to_string()); + } + + let mut rng = Rng::new(seed); + let mut output: Vec = Vec::with_capacity(slots.len()); + + for slot in slots { + if slot.is_cpu { + let i = rng.range_usize(remaining.len()); + output.push(remaining.remove(i).to_string()); + } else { + let team = TEAMS + .get(slot.team_idx) + .ok_or_else(|| "Manual team selection is out of range".to_string())?; + output.push((*team).to_string()); + } + } + + Ok(output) +} + +pub fn resolve_quick_single_teams( + home: Option<&str>, + away: Option<&str>, + selection_seed: u64, +) -> Result, String> { + let mut slots = vec![ + TeamSlotDraft { + is_cpu: home.is_none(), + team_idx: 0, + }, + TeamSlotDraft { + is_cpu: away.is_none(), + team_idx: 1, + }, + ]; + + if let Some(home_team) = home { + let idx = TEAMS + .iter() + .position(|t| *t == home_team) + .ok_or_else(|| format!("Unknown home team: {home_team}"))?; + slots[0].team_idx = idx; + } + if let Some(away_team) = away { + let idx = TEAMS + .iter() + .position(|t| *t == away_team) + .ok_or_else(|| format!("Unknown away team: {away_team}"))?; + slots[1].team_idx = idx; + } + + resolve_teams_from_slots(&slots, selection_seed) } diff --git a/src/main.rs b/src/main.rs index 81e091e..d95fe1c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,7 +11,7 @@ use std::io::{self, Write}; use clap::{Parser, Subcommand, ValueEnum}; -use app::{run_tui, App, Speed}; +use app::{resolve_quick_single_teams, run_tui, App, Speed}; use data::{display_name, TEAMS}; use export::simulation_to_csv_bytes; use sim::{run_simulation, SimulationType}; @@ -35,9 +35,9 @@ struct Cli { enum Commands { Quick { #[arg(long)] - home: String, + home: Option, #[arg(long)] - away: String, + away: Option, }, List, Export { @@ -76,10 +76,7 @@ fn main() -> io::Result<()> { let app = App::new(base_seed, cli.speed); run_tui(app) } - Some(Commands::Quick { home, away }) => { - quick_mode(&home, &away, base_seed); - Ok(()) - } + Some(Commands::Quick { home, away }) => quick_mode(home, away, base_seed), Some(Commands::List) => { for team in TEAMS { println!("{}", display_name(team)); @@ -90,8 +87,11 @@ fn main() -> io::Result<()> { } } -fn quick_mode(home: &str, away: &str, base_seed: u64) { - let teams = vec![home.to_string(), away.to_string()]; +fn quick_mode(home: Option, away: Option, base_seed: u64) -> io::Result<()> { + let teams = + resolve_quick_single_teams(home.as_deref(), away.as_deref(), derive_seed(base_seed, 1)) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + let seed = derive_seed(base_seed, 1); let mut rng = Rng::new(seed); let prepared = run_simulation(SimulationType::Single, &teams, &mut rng); @@ -108,6 +108,8 @@ fn quick_mode(home: &str, away: &str, base_seed: u64) { println!("{}", line); } } + + Ok(()) } fn export_mode(mode: ModeArg, out: String, teams: Vec, base_seed: u64) -> io::Result<()> { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 7fa114e..78145d5 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -19,7 +19,7 @@ pub fn draw(f: &mut Frame<'_>, app: &App) { ]) .split(f.area()); - let header = Paragraph::new("MentalNet SoccerCloud | n=single l=league4 o=knockout4 s=start c=clone d=delete e=export v=detail q=quit") + let header = Paragraph::new("MentalNet SoccerCloud | n/l/o create | s start | c clone | d delete | e export | v detail | q quit") .block(Block::default().title("Dashboard").borders(Borders::ALL)) .style(Style::default().fg(Color::Cyan)); f.render_widget(header, areas[0]); @@ -31,11 +31,15 @@ pub fn draw(f: &mut Frame<'_>, app: &App) { } let footer = Paragraph::new(format!( - "{} | speed={} (1/2/4/0)", + "{} | speed={} (1/2/4/0) | modal: m=manual p=cpu [ ] team Enter=create Esc=cancel", app.status_line, app.speed.label() )) .block(Block::default().borders(Borders::ALL).title("Status")) .style(Style::default().fg(Color::Green)); f.render_widget(footer, areas[2]); + + if let Some(draft) = &app.create_draft { + modal::render(f, f.area(), app, draft); + } } diff --git a/src/ui/modal.rs b/src/ui/modal.rs index d3434f9..e750605 100644 --- a/src/ui/modal.rs +++ b/src/ui/modal.rs @@ -1,2 +1,100 @@ -// Placeholder module for future interactive creation modal. -// Current MVP uses keyboard shortcuts for fast instance creation. +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph}; + +use crate::app::{App, CreateDraft}; +use crate::data::TEAMS; + +pub fn render(f: &mut Frame<'_>, area: Rect, app: &App, draft: &CreateDraft) { + let popup = centered_rect(70, 70, area); + f.render_widget(Clear, popup); + + let frame = Block::default() + .title("Create Simulation") + .borders(Borders::ALL) + .style(Style::default().fg(Color::White).bg(Color::Black)); + f.render_widget(frame, popup); + + let inner = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(2), + Constraint::Min(4), + Constraint::Length(2), + ]) + .split(popup); + + let top = Paragraph::new(format!( + "Mode: {} | next-seed={} | select slot with up/down", + draft.mode_label(), + app.next_instance_seed_preview() + )); + f.render_widget(top, inner[0]); + + let mut rows = Vec::with_capacity(draft.slots.len()); + for (i, slot) in draft.slots.iter().enumerate() { + let slot_name = slot_label(draft.slots.len(), i); + let content = if slot.is_cpu { + format!("{}: CPU auto-fill", slot_name) + } else { + format!("{}: MANUAL -> {}", slot_name, TEAMS[slot.team_idx]) + }; + let mut item = ListItem::new(content); + if i == draft.selected_slot { + item = item.style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + } + rows.push(item); + } + let list = List::new(rows).block(Block::default().title("Team Slots").borders(Borders::ALL)); + f.render_widget(list, inner[1]); + + let help = Paragraph::new( + "m=manual, p=cpu, [ / ] or left/right change manual team, Enter=create, Esc=cancel", + ); + f.render_widget(help, inner[2]); +} + +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + + let horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vertical[1]); + + horizontal[1] +} + +fn slot_label(total: usize, i: usize) -> &'static str { + if total == 2 { + match i { + 0 => "Home", + 1 => "Away", + _ => "Slot", + } + } else { + match i { + 0 => "Team A", + 1 => "Team B", + 2 => "Team C", + 3 => "Team D", + _ => "Slot", + } + } +}