use std::collections::{HashMap, VecDeque};
use bevy::ecs::system::SystemId;
use bevy::prelude::*;
use bevy::remote::{error_codes, BrpError, BrpResult};
use serde::Deserialize;
use serde_json::{json, Value};
pub const BEAT_SECONDS: f32 = 2.0;
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Step {
pub label: String,
#[serde(default)]
pub goal: String,
#[serde(default, rename = "do")]
pub hints: Vec<String>,
#[serde(default)]
pub checkpoint: Option<String>,
#[serde(default)]
pub with: Option<Value>,
#[serde(default)]
pub recovery: String,
}
#[derive(Debug, Clone, Default)]
pub struct StepRecord {
pub label: String,
pub runs: u32,
pub passes: u32,
pub seconds: f32,
}
#[derive(Resource, Default)]
pub struct Guide {
steps: Vec<Step>,
at: usize,
transcript: Vec<StepRecord>,
elapsed: f32,
beat: f32,
finished: Option<(String, bool)>,
announced: Option<usize>,
unreported: VecDeque<String>,
checkpoint_error: Option<String>,
generation: u64,
pub visible: bool,
}
impl Guide {
pub fn post(&mut self, steps: Vec<Step>) {
let same = self.transcript.len() == steps.len()
&& self
.transcript
.iter()
.zip(steps.iter())
.all(|(r, s)| r.label == s.label);
if !same {
self.transcript = steps
.iter()
.map(|s| StepRecord {
label: s.label.clone(),
..Default::default()
})
.collect();
}
self.steps = steps;
self.at = 0;
self.elapsed = 0.0;
self.beat = 0.0;
self.finished = None;
self.announced = None;
self.unreported.clear();
self.checkpoint_error = None;
self.generation += 1;
self.visible = true;
self.enter();
}
pub fn clear(&mut self) {
self.steps.clear();
self.at = 0;
self.visible = false;
self.finished = None;
self.beat = 0.0;
self.unreported.clear();
self.checkpoint_error = None;
}
pub fn current(&self) -> Option<&Step> {
self.steps.get(self.at)
}
pub fn position(&self) -> (usize, usize) {
(self.at.min(self.steps.len()) + 1, self.steps.len())
}
pub fn transcript(&self) -> &[StepRecord] {
&self.transcript
}
fn enter(&mut self) {
if let Some(r) = self.transcript.get_mut(self.at) {
r.runs += 1;
}
self.elapsed = 0.0;
}
pub fn advance(&mut self, passed: bool) -> Option<String> {
let label = self.steps.get(self.at).map(|s| s.label.clone())?;
if let Some(r) = self.transcript.get_mut(self.at) {
if passed {
r.passes += 1;
r.seconds = self.elapsed;
}
}
self.at += 1;
self.checkpoint_error = None;
self.finished = Some((label.clone(), passed));
self.beat = BEAT_SECONDS;
if self.at < self.steps.len() {
self.enter();
}
Some(label)
}
pub fn report(&self) -> Value {
let rows: Vec<Value> = self
.transcript
.iter()
.map(|r| {
json!({
"step": r.label,
"passes": r.passes,
"runs": r.runs,
"seconds": r.seconds,
})
})
.collect();
let (step, of) = self.position();
let waiting = self
.current()
.map(|s| s.checkpoint.is_none())
.unwrap_or(false);
let mut report = json!({
"at": step,
"of": of,
"step": self.current().map(|s| s.label.clone()),
"waiting_on_a_person": waiting,
"checkpoint": self.current().and_then(|s| s.checkpoint.clone()),
"steps": rows,
});
if let Some(why) = &self.checkpoint_error {
report["checkpoint_error"] = json!(why);
}
report
}
}
#[derive(Resource, Default)]
pub struct Checkpoints(HashMap<String, SystemId<In<Value>, bool>>);
impl Checkpoints {
pub fn register(&mut self, name: impl Into<String>, system: SystemId<In<Value>, bool>) {
self.0.insert(name.into(), system);
}
pub fn get(&self, name: &str) -> Option<SystemId<In<Value>, bool>> {
self.0.get(name).copied()
}
pub fn names(&self) -> Vec<String> {
let mut out: Vec<String> = self.0.keys().cloned().collect();
out.sort();
out
}
}
fn invalid_params(message: String) -> BrpError {
BrpError { code: error_codes::INVALID_PARAMS, message, data: None }
}
#[derive(Debug, Default, Deserialize)]
pub struct GuideParams {
#[serde(default)]
pub steps: Option<Vec<Step>>,
#[serde(default)]
pub read: bool,
#[serde(default)]
pub clear: bool,
#[serde(default)]
pub skip: bool,
#[serde(default)]
pub visible: Option<bool>,
}
pub fn handle_guide(
In(params): In<Option<Value>>,
mut guide: ResMut<Guide>,
checkpoints: Res<Checkpoints>,
) -> BrpResult {
let p: GuideParams = match params.as_ref() {
Some(v) => serde_json::from_value(v.clone())
.map_err(|e| invalid_params(format!("invalid guide params: {e}")))?,
None => GuideParams::default(),
};
let verbs: Vec<&str> = [
p.steps.is_some().then_some("steps"),
p.read.then_some("read"),
p.clear.then_some("clear"),
p.skip.then_some("skip"),
]
.into_iter()
.flatten()
.collect();
if verbs.len() > 1 {
return Err(invalid_params(format!(
"one verb per call, got {}: only the first would have run and the reply would have \
said success for all of them. Send them as separate calls. Example: {{\"skip\": true}}",
verbs.join(" and ")
)));
}
if p.visible.is_some() && (p.read || p.clear) {
return Err(invalid_params(format!(
"`visible` does nothing with `{}`: read changes nothing and clear already takes the \
card down. Use it alone, or with `steps` or `skip`. Example: {{\"visible\": false}}",
if p.read { "read" } else { "clear" }
)));
}
if p.read {
return Ok(json!({ "success": true, "guide": guide.report() }));
}
if p.clear {
guide.clear();
return Ok(json!({ "success": true, "message": "script taken down" }));
}
if p.skip {
if guide.current().is_none() {
return Err(invalid_params(
"no current step to skip: no script is posted, or it is finished. Read the guide \
to see where it is. Example: {\"read\": true}"
.to_string(),
));
}
if let Some(v) = p.visible {
guide.visible = v;
}
let label = guide.advance(false);
return Ok(json!({
"success": true,
"skipped": label,
"guide": guide.report(),
}));
}
if let Some(steps) = p.steps {
if steps.is_empty() {
return Err(invalid_params(
"a script needs at least one step; use clear to take one down".to_string(),
));
}
if let Some((label, name)) = steps.iter().find_map(|s| {
let name = s.checkpoint.as_deref()?;
checkpoints.get(name).is_none().then(|| (s.label.as_str(), name))
}) {
let known = checkpoints.names();
return Err(invalid_params(format!(
"step \"{label}\" names checkpoint `{name}`, which this app has not registered, so \
it would wait for ever. This app registers: {}. Name one of those, or use \
checkpoint: null for a step only the person can judge. Nothing was posted.",
if known.is_empty() { "none".to_string() } else { known.join(", ") }
)));
}
let n = steps.len();
guide.post(steps);
if let Some(v) = p.visible {
guide.visible = v;
}
return Ok(json!({
"success": true,
"message": format!("{n} step(s) posted; the app shows the first one"),
}));
}
if let Some(v) = p.visible {
guide.visible = v;
}
Ok(json!({ "success": true, "guide": guide.report() }))
}
pub fn evaluate_checkpoint(guide: Res<Guide>, mut commands: Commands) {
if guide.current().is_some_and(|step| step.checkpoint.is_some()) {
commands.queue(|world: &mut World| {
let outcome = run_current_checkpoint(world);
if let Some(mut guide) = world.get_resource_mut::<Guide>() {
guide.checkpoint_error = outcome.err().map(|e| e.message);
}
});
}
}
fn unregistered(world: &World, name: &str) -> BrpError {
let known = world.get_resource::<Checkpoints>().map(Checkpoints::names).unwrap_or_default();
invalid_params(format!(
"no checkpoint named `{name}`. This host registers: {}. A script naming a condition \
nobody watches would wait for ever.",
if known.is_empty() { "none".to_string() } else { known.join(", ") }
))
}
fn run_current_checkpoint(world: &mut World) -> Result<(), BrpError> {
let Some((name, label, args)) = world.get_resource::<Guide>().and_then(|guide| {
let step = guide.current()?;
Some((step.checkpoint.clone()?, step.label.clone(), step.with.clone().unwrap_or(Value::Null)))
}) else {
return Ok(());
};
let Some(system) = world.get_resource::<Checkpoints>().and_then(|c| c.get(&name)) else {
return Err(unregistered(world, &name));
};
match world.run_system_with(system, args) {
Ok(true) => {
let mut guide = world.resource_mut::<Guide>();
guide.advance(true);
guide.unreported.push_back(label);
Ok(())
}
Ok(false) => Ok(()),
Err(e) => Err(BrpError {
code: error_codes::INTERNAL_ERROR,
message: format!("checkpoint `{name}` could not run: {e}"),
data: None,
}),
}
}
pub fn watch_guide(In(_params): In<Option<Value>>, world: &mut World) -> BrpResult<Option<Value>> {
let Some(mut guide) = world.get_resource_mut::<Guide>() else {
return Err(BrpError {
code: error_codes::INTERNAL_ERROR,
message: "no Guide resource; add DebuggerPlugin or init_resource::<Guide>()".to_string(),
data: None,
});
};
if let Some(label) = guide.unreported.pop_front() {
let report = guide.report();
return Ok(Some(json!({ "passed": label, "guide": report })));
}
let (name, label) = match guide.current() {
Some(step) => (step.checkpoint.clone(), step.label.clone()),
None => {
if !announce_once(world) {
return Ok(None);
}
let report = world.resource::<Guide>().report();
return Ok(Some(json!({ "done": true, "guide": report })));
}
};
let Some(name) = name else {
if !announce_once(world) {
return Ok(None);
}
return Ok(Some(json!({
"waiting_on_a_person": true,
"step": label,
"message": "this step has no checkpoint; advance it with skip once it has been judged",
})));
};
let problem = if world.get_resource::<Checkpoints>().is_some_and(|c| c.get(&name).is_some()) {
world.resource::<Guide>().checkpoint_error.clone().map(|message| BrpError {
code: error_codes::INTERNAL_ERROR,
message,
data: None,
})
} else {
Some(unregistered(world, &name))
};
match problem {
Some(error) if announce_once(world) => Err(error),
_ => Ok(None),
}
}
fn announce_once(world: &mut World) -> bool {
let mut guide = world.resource_mut::<Guide>();
let at = guide.at;
if guide.announced == Some(at) {
return false;
}
guide.announced = Some(at);
true
}
pub fn tick_guide(time: Res<Time>, mut guide: ResMut<Guide>) {
let dt = time.delta_secs();
if guide.beat > 0.0 {
guide.beat = (guide.beat - dt).max(0.0);
if guide.beat == 0.0 {
guide.finished = None;
}
} else if guide.current().is_some() {
guide.elapsed += dt;
}
}
#[derive(Component)]
pub struct GuideOverlay;
#[derive(Component)]
struct GuideBody;
#[derive(Resource, Default, PartialEq, Eq)]
struct Showing(Option<(usize, bool, u64)>);
#[derive(Resource)]
pub struct GuidePlacement {
pub top: f32,
pub width: f32,
}
impl Default for GuidePlacement {
fn default() -> Self {
GuidePlacement { top: 12.0, width: 440.0 }
}
}
fn spawn_guide_overlay(mut commands: Commands) {
commands
.spawn((
GuideOverlay,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(50.0),
flex_direction: FlexDirection::Column,
row_gap: Val::Px(4.0),
padding: UiRect::all(Val::Px(10.0)),
border: UiRect::all(Val::Px(1.0)),
display: Display::None,
..default()
},
bevy::picking::Pickable::IGNORE,
GlobalZIndex(500),
BackgroundColor(Color::srgba(0.05, 0.05, 0.06, 0.97)),
BorderColor::all(Color::srgba(0.92, 0.80, 0.35, 0.45)),
))
.with_children(|p| {
p.spawn((GuideBody, Node { flex_direction: FlexDirection::Column, row_gap: Val::Px(3.0), ..default() }));
});
}
fn paint_guide(
mut commands: Commands,
guide: Res<Guide>,
place: Res<GuidePlacement>,
mut showing: ResMut<Showing>,
mut roots: Query<&mut Node, With<GuideOverlay>>,
bodies: Query<Entity, With<GuideBody>>,
) {
let want = guide.visible && (guide.current().is_some() || guide.finished.is_some());
for mut node in &mut roots {
let display = if want { Display::Flex } else { Display::None };
if node.display != display {
node.display = display;
}
let top = Val::Px(place.top);
let width = Val::Px(place.width);
let centre = UiRect::left(Val::Px(-place.width / 2.0));
if node.top != top || node.width != width || node.margin != centre {
node.top = top;
node.width = width;
node.margin = centre;
}
}
let key = want.then_some((guide.at, guide.finished.is_some(), guide.generation));
if showing.0 == key {
return;
}
showing.0 = key;
let Some(_) = key else { return };
let (step_no, of) = guide.position();
let finished = guide.finished.clone();
let current = guide.current().cloned();
for body in &bodies {
commands.entity(body).despawn_related::<Children>();
let finished = finished.clone();
let current = current.clone();
commands.entity(body).with_children(|p| {
if let Some((done, passed)) = finished {
let (mark, colour) = if passed {
("OK", Color::srgb(0.52, 0.78, 0.48))
} else {
("SKIPPED", Color::srgb(0.68, 0.66, 0.62))
};
line(p, format!("{mark} {done}"), colour, 13.0);
return;
}
let Some(step) = current else { return };
line(p, format!("STEP {step_no} OF {of} {}", step.label), Color::srgb(0.92, 0.80, 0.35), 13.0);
if step.checkpoint.is_none() {
line(
p,
"-> yours to judge: no key advances this step. Answer the agent in chat."
.to_owned(),
Color::srgb(0.55, 0.78, 0.95),
13.0,
);
}
if !step.goal.is_empty() {
line(p, step.goal.clone(), Color::srgb(0.62, 0.60, 0.58), 11.0);
}
for hint in &step.hints {
line(p, format!(" {hint}"), Color::srgb(0.88, 0.86, 0.83), 12.0);
}
if !step.recovery.is_empty() {
line(p, format!("if not: {}", step.recovery), Color::srgb(0.62, 0.60, 0.58), 11.0);
}
});
}
}
fn line(p: &mut ChildSpawnerCommands, text: String, colour: Color, size: f32) {
p.spawn((Text::new(text), TextColor(colour), TextFont::from_font_size(size)));
}
pub struct GuideOverlayPlugin;
impl Plugin for GuideOverlayPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<Showing>()
.init_resource::<GuidePlacement>()
.add_systems(Startup, spawn_guide_overlay)
.add_systems(Update, (tick_guide, paint_guide).chain());
}
}