Manual Team Selection + CPU Auto-Fill (TUI + Quick Mode)

This commit is contained in:
markmental 2026-02-10 17:21:40 -05:00
commit e3242583b6
5 changed files with 374 additions and 35 deletions

View file

@ -37,3 +37,20 @@
- No external data files are required at runtime. - No external data files are required at runtime.
- Team data is fully embedded in the binary. - Team data is fully embedded in the binary.
- CSV export format is mode-specific and generated with std I/O. - 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.

View file

@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::fs::File; use std::fs::File;
use std::io::{self, Write}; use std::io::{self, Write};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@ -52,10 +53,50 @@ pub struct App {
pub instances: Vec<SimulationInstance>, pub instances: Vec<SimulationInstance>,
pub selected: usize, pub selected: usize,
pub show_detail: bool, pub show_detail: bool,
pub create_draft: Option<CreateDraft>,
pub status_line: String, pub status_line: String,
next_id: usize, 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<TeamSlotDraft>,
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 { impl App {
pub fn new(base_seed: u64, speed: Speed) -> Self { pub fn new(base_seed: u64, speed: Speed) -> Self {
Self { Self {
@ -64,24 +105,113 @@ impl App {
instances: Vec::with_capacity(MAX_INSTANCES), instances: Vec::with_capacity(MAX_INSTANCES),
selected: 0, selected: 0,
show_detail: false, show_detail: false,
create_draft: None,
status_line: format!("Ready. Seed={base_seed}, Speed={}", speed.label()), status_line: format!("Ready. Seed={base_seed}, Speed={}", speed.label()),
next_id: 0, 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 { if self.instances.len() >= MAX_INSTANCES {
self.status_line = format!("Instance limit reached ({MAX_INSTANCES})"); self.status_line = format!("Instance limit reached ({MAX_INSTANCES})");
return; return;
} }
let Some(draft) = self.create_draft.clone() else {
return;
};
let id = self.next_id; let id = self.next_id;
let seed = derive_seed(self.base_seed, id as u64 + 1); 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.instances.push(instance);
self.selected = self.instances.len().saturating_sub(1); self.selected = self.instances.len().saturating_sub(1);
self.next_id += 1; self.next_id += 1;
self.status_line = format!("Created sim-{id}"); self.status_line = format!("Created sim-{id}");
self.create_draft = None;
} }
pub fn start_selected(&mut self) { pub fn start_selected(&mut self) {
@ -146,14 +276,6 @@ impl App {
self.status_line = format!("Speed set to {}", self.speed.label()); self.status_line = format!("Speed set to {}", self.speed.label());
} }
fn random_teams_for_mode(&self, sim_type: SimulationType, seed: u64) -> Vec<String> {
let count = match sim_type {
SimulationType::Single => 2,
SimulationType::League4 | SimulationType::Knockout4 => 4,
};
unique_random_teams(count, seed)
}
pub fn tick(&mut self) { pub fn tick(&mut self) {
let frames = self.speed.frames_per_tick(); let frames = self.speed.frames_per_tick();
for inst in &mut self.instances { for inst in &mut self.instances {
@ -205,13 +327,34 @@ pub fn run_tui(mut app: App) -> io::Result<()> {
if event::poll(timeout)? { if event::poll(timeout)? {
if let Event::Key(key) = event::read()? { if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press { 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 { match key.code {
KeyCode::Char('q') => running = false, KeyCode::Char('q') => running = false,
KeyCode::Up | KeyCode::Char('k') => app.select_prev(), KeyCode::Up | KeyCode::Char('k') => app.select_prev(),
KeyCode::Down | KeyCode::Char('j') => app.select_next(), KeyCode::Down | KeyCode::Char('j') => app.select_next(),
KeyCode::Char('n') => app.create_instance(SimulationType::Single), KeyCode::Char('n') => app.open_create_draft(SimulationType::Single),
KeyCode::Char('l') => app.create_instance(SimulationType::League4), KeyCode::Char('l') => app.open_create_draft(SimulationType::League4),
KeyCode::Char('o') => app.create_instance(SimulationType::Knockout4), KeyCode::Char('o') => app.open_create_draft(SimulationType::Knockout4),
KeyCode::Char('s') => app.start_selected(), KeyCode::Char('s') => app.start_selected(),
KeyCode::Char('c') => app.clone_selected(), KeyCode::Char('c') => app.clone_selected(),
KeyCode::Char('d') => app.delete_selected(), KeyCode::Char('d') => app.delete_selected(),
@ -239,13 +382,88 @@ pub fn run_tui(mut app: App) -> io::Result<()> {
Ok(()) Ok(())
} }
fn unique_random_teams(count: usize, seed: u64) -> Vec<String> { fn slot_count_for_mode(sim_type: SimulationType) -> usize {
let mut rng = Rng::new(seed); match sim_type {
let mut pool: Vec<&str> = TEAMS.to_vec(); SimulationType::Single => 2,
let mut picked = Vec::with_capacity(count); SimulationType::League4 | SimulationType::Knockout4 => 4,
while picked.len() < count && !pool.is_empty() {
let i = rng.range_usize(pool.len());
picked.push(pool.remove(i).to_string());
} }
picked }
fn resolve_teams_from_slots(slots: &[TeamSlotDraft], seed: u64) -> Result<Vec<String>, 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<String> = 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<Vec<String>, 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)
} }

View file

@ -11,7 +11,7 @@ use std::io::{self, Write};
use clap::{Parser, Subcommand, ValueEnum}; 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 data::{display_name, TEAMS};
use export::simulation_to_csv_bytes; use export::simulation_to_csv_bytes;
use sim::{run_simulation, SimulationType}; use sim::{run_simulation, SimulationType};
@ -35,9 +35,9 @@ struct Cli {
enum Commands { enum Commands {
Quick { Quick {
#[arg(long)] #[arg(long)]
home: String, home: Option<String>,
#[arg(long)] #[arg(long)]
away: String, away: Option<String>,
}, },
List, List,
Export { Export {
@ -76,10 +76,7 @@ fn main() -> io::Result<()> {
let app = App::new(base_seed, cli.speed); let app = App::new(base_seed, cli.speed);
run_tui(app) run_tui(app)
} }
Some(Commands::Quick { home, away }) => { Some(Commands::Quick { home, away }) => quick_mode(home, away, base_seed),
quick_mode(&home, &away, base_seed);
Ok(())
}
Some(Commands::List) => { Some(Commands::List) => {
for team in TEAMS { for team in TEAMS {
println!("{}", display_name(team)); println!("{}", display_name(team));
@ -90,8 +87,11 @@ fn main() -> io::Result<()> {
} }
} }
fn quick_mode(home: &str, away: &str, base_seed: u64) { fn quick_mode(home: Option<String>, away: Option<String>, base_seed: u64) -> io::Result<()> {
let teams = vec![home.to_string(), away.to_string()]; 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 seed = derive_seed(base_seed, 1);
let mut rng = Rng::new(seed); let mut rng = Rng::new(seed);
let prepared = run_simulation(SimulationType::Single, &teams, &mut rng); 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); println!("{}", line);
} }
} }
Ok(())
} }
fn export_mode(mode: ModeArg, out: String, teams: Vec<String>, base_seed: u64) -> io::Result<()> { fn export_mode(mode: ModeArg, out: String, teams: Vec<String>, base_seed: u64) -> io::Result<()> {

View file

@ -19,7 +19,7 @@ pub fn draw(f: &mut Frame<'_>, app: &App) {
]) ])
.split(f.area()); .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)) .block(Block::default().title("Dashboard").borders(Borders::ALL))
.style(Style::default().fg(Color::Cyan)); .style(Style::default().fg(Color::Cyan));
f.render_widget(header, areas[0]); f.render_widget(header, areas[0]);
@ -31,11 +31,15 @@ pub fn draw(f: &mut Frame<'_>, app: &App) {
} }
let footer = Paragraph::new(format!( 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.status_line,
app.speed.label() app.speed.label()
)) ))
.block(Block::default().borders(Borders::ALL).title("Status")) .block(Block::default().borders(Borders::ALL).title("Status"))
.style(Style::default().fg(Color::Green)); .style(Style::default().fg(Color::Green));
f.render_widget(footer, areas[2]); f.render_widget(footer, areas[2]);
if let Some(draft) = &app.create_draft {
modal::render(f, f.area(), app, draft);
}
} }

View file

@ -1,2 +1,100 @@
// Placeholder module for future interactive creation modal. use ratatui::layout::{Constraint, Direction, Layout, Rect};
// Current MVP uses keyboard shortcuts for fast instance creation. 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",
}
}
}