pub mod claims;
pub mod ladders;
pub mod markets;
mod options;
mod worker;
mod write;
use std::marker::PhantomData;
use std::time::{Duration, Instant};
use bevy_app::{App, Startup, Update};
use bevy_ecs::change_detection::DetectChanges;
use bevy_ecs::event::EntityEvent;
use bevy_ecs::hierarchy::ChildOf;
use bevy_ecs::prelude::{
Commands, Component, Entity, IntoScheduleConfigs, On, Query, Res, ResMut, Resource, With,
};
use bevy_input::keyboard::{Key, KeyboardInput};
use bevy_input_focus::FocusedInput;
use bevy_ui::{FlexDirection, Node};
use plurimus::core::UiWidget;
use plurimus::term::bevy_compat::HeldModifiers;
use plurimus::ui::{KeyBinding, first_bound};
use plurimus::widgets::ratatui_widgets::paragraph::Paragraph;
use retiretui_engine::market::Progress;
use retiretui_engine::plan::{Issue, Plan};
pub use claims::Claims;
pub use ladders::Ladders;
pub use worker::Searches;
pub(crate) use worker::Worker;
pub use write::OVERLAY_OVER;
use super::command;
use super::edit::Draft;
use super::layout::{self, Body, fixed, growing, placed};
use super::nav::{self, Page};
use super::pane::Framed;
use super::theme::{Repainted, Theme};
pub fn plugin(app: &mut App) {
app.init_resource::<Searches>();
app.add_plugins((ladders::plugin, claims::plugin, markets::plugin));
}
const NOTHING_SEARCHED_YET: &str = "nothing searched yet";
pub trait Found: Send + Sync + 'static {
const NOTHING_SEARCHED: &'static str;
const IS_COUNTED: bool = false;
fn laid(&self, plan: &Plan, nominal: bool) -> options::Laid;
}
#[derive(Clone, Copy)]
pub(crate) struct ToolPage {
surface: Page,
panes: fn(&mut Commands, Entity),
}
#[derive(Component)]
pub struct HelpLine(pub Page);
pub(crate) fn spawn_help(commands: &mut Commands, view: Entity, surface: Page) {
commands.spawn((
HelpLine(surface),
UiWidget::default(),
fixed(1.0),
placed(),
ChildOf(view),
));
}
pub(crate) fn show_help(
lines: &mut Query<(&mut UiWidget, &HelpLine)>,
surface: Page,
text: &str,
theme: &Theme,
) {
let on_page = lines.iter_mut().filter(|(_, help)| help.0 == surface);
for (mut widget, _) in on_page {
*widget = UiWidget::new(Paragraph::new(text.to_owned()).style(theme.dimmed()));
}
}
const ENTER_KEYS: &[(KeyBinding, ())] = &[(KeyBinding::new(Key::Enter), ())];
struct Answer<R> {
plan: Plan,
found: Result<R, Vec<Issue>>,
}
struct Running<R> {
worker: Worker<Answer<R>>,
since: Instant,
total: Option<usize>,
}
#[derive(Resource)]
pub struct Tool<R: Found> {
running: Option<Running<R>>,
found: Option<(R, Duration)>,
refused: Option<String>,
highlighted: usize,
}
impl<R: Found> Default for Tool<R> {
fn default() -> Self {
Self {
running: None,
found: None,
refused: None,
highlighted: 0,
}
}
}
fn is_due<K>(draft: &Draft, searched: Option<&K>, is_same: impl FnOnce(&K) -> bool) -> bool {
draft.issues().is_empty() && !searched.is_some_and(is_same)
}
impl<R: Found> Tool<R> {
fn start(
&mut self,
plan: Plan,
work: impl FnOnce(&Plan) -> Result<R, Vec<Issue>> + Send + 'static,
) {
self.spawn(plan, None, move |plan, _| work(plan));
self.found = None;
self.refused = None;
self.highlighted = 0;
}
fn restart(
&mut self,
plan: Plan,
total: usize,
work: impl FnOnce(&Plan, &Progress) -> Result<R, Vec<Issue>> + Send + 'static,
) {
self.spawn(plan, Some(total), work);
}
fn spawn(
&mut self,
plan: Plan,
total: Option<usize>,
work: impl FnOnce(&Plan, &Progress) -> Result<R, Vec<Issue>> + Send + 'static,
) {
let worker = Worker::spawn(move |progress| {
let found = work(&plan, progress);
Answer { plan, found }
});
self.running = Some(Running {
worker,
since: Instant::now(),
total,
});
}
fn has_answered(&self) -> bool {
let running = self.running.as_ref();
running.is_some_and(|running| running.worker.is_finished())
}
fn receive(&mut self, draft: &Draft) {
let Some(running) = self.running.take() else {
return;
};
let took = running.since.elapsed();
let Some(answer) = running.worker.join() else {
return;
};
if answer.plan != draft.plan {
return;
}
match answer.found {
Ok(found) => self.found = Some((found, took)),
Err(issues) => self.refused = issues.first().map(|issue| issue.message.clone()),
}
}
pub fn found(&self) -> Option<&R> {
self.found.as_ref().map(|(found, _)| found)
}
fn highlighted(&self) -> Option<usize> {
self.highlighted.checked_sub(1)
}
fn note(&self) -> String {
match (&self.running, &self.found) {
(Some(running), _) => match running.total {
Some(total) => running_text(running.worker.progress.done(), total),
None => format!("searching… {}s", running.since.elapsed().as_secs()),
},
(None, Some(_)) if R::IS_COUNTED => String::new(),
(None, Some((_, took))) => format!("{:.1}s", took.as_secs_f32()),
(None, None) => String::new(),
}
}
fn said(&self) -> String {
self.refused
.clone()
.unwrap_or_else(|| R::NOTHING_SEARCHED.to_owned())
}
fn is_running(&self) -> bool {
self.running.is_some()
}
}
pub(crate) fn count_text(count: usize) -> String {
crate::commands::table::grouped(u64::try_from(count).unwrap_or(u64::MAX))
}
pub(crate) fn running_text(done: usize, total: usize) -> String {
format!("running {} of {}", count_text(done), count_text(total))
}
#[derive(Component)]
pub struct ResultPane<R>(PhantomData<R>);
impl<R> Default for ResultPane<R> {
fn default() -> Self {
Self(PhantomData)
}
}
fn install<R: Found>(app: &mut App, page: &'static ToolPage) {
app.init_resource::<Tool<R>>();
let spawn = move |bodies: Query<Entity, With<Body>>, mut commands: Commands| {
if let Ok(body) = bodies.single() {
spawn_tool(&mut commands, body, page);
}
};
app.add_systems(Startup, spawn.after(layout::spawn_frame));
app.add_systems(Update, poll_search::<R>.before(Repainted));
}
fn spawn_tool(commands: &mut Commands, body: Entity, page: &'static ToolPage) {
let view = nav::spawn_surface(commands, body, Some(page.surface));
let row = Node {
flex_direction: FlexDirection::Row,
..growing()
};
let row = commands.spawn((row, ChildOf(view))).id();
(page.panes)(commands, row);
spawn_help(commands, view, page.surface);
}
#[derive(Component, Clone, Copy)]
pub struct EnterRuns(pub &'static str);
pub fn handle_enter(
mut input: On<FocusedInput<KeyboardInput>>,
held: HeldModifiers,
runs: Query<&EnterRuns>,
mut commands: Commands,
) {
let Ok(&EnterRuns(name)) = runs.get(input.event_target()) else {
return;
};
if first_bound(ENTER_KEYS, &input.input, held.get()).is_none() {
return;
}
input.propagate(false);
command::defer_named(&mut commands, name);
}
fn poll_search<R: Found>(
mut tool: ResMut<Tool<R>>,
draft: Res<Draft>,
mut panes: Query<&mut Framed, With<ResultPane<R>>>,
) {
if tool.has_answered() {
tool.receive(&draft);
}
if tool.running.is_none() && !tool.is_changed() {
return;
}
let note = tool.note();
for mut pane in &mut panes {
Framed::renote(&mut pane, ¬e);
}
}
#[cfg(test)]
pub fn settle_all(app: &mut bevy_app::App) {
settle::<retiretui_engine::optimize::ClaimSearch>(app);
settle::<ladders::Swept>(app);
settle::<retiretui_engine::market::MonteCarlo>(app);
settle::<retiretui_engine::market::Runs>(app);
settle_until_idle(app, |app| {
let successes = app.world().resource::<super::success::Successes>();
successes.is_running()
});
settle_until_idle(app, |app| {
app.world()
.resource::<super::overview::Better>()
.is_running()
});
}
#[cfg(test)]
fn settle<R: Found>(app: &mut bevy_app::App) {
settle_until_idle(app, |app| app.world().resource::<Tool<R>>().is_running());
}
#[cfg(test)]
fn settle_until_idle(app: &mut bevy_app::App, is_running: impl Fn(&bevy_app::App) -> bool) {
for _ in 0..500 {
app.update();
if !is_running(app) {
app.update();
if !is_running(app) {
return;
}
}
std::thread::sleep(Duration::from_millis(10));
}
panic!("the search never answered");
}