use std::collections::HashMap;
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>,
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.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;
}
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.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);
json!({
"at": step,
"of": of,
"step": self.current().map(|s| s.label.clone()),
"waiting_on_a_person": waiting,
"steps": rows,
})
}
}
#[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>) -> 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(),
};
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 let Some(v) = p.visible {
guide.visible = v;
}
if p.skip {
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(),
));
}
let n = steps.len();
guide.post(steps);
return Ok(json!({
"success": true,
"message": format!("{n} step(s) posted; the app shows the first one"),
}));
}
Ok(json!({ "success": true, "guide": guide.report() }))
}
pub fn watch_guide(In(_params): In<Option<Value>>, world: &mut World) -> BrpResult<Option<Value>> {
let (name, label, done) = {
let Some(guide) = world.get_resource::<Guide>() else {
return Err(BrpError {
code: error_codes::INTERNAL_ERROR,
message: "no Guide resource; add DebuggerPlugin or init_resource::<Guide>()"
.to_string(),
data: None,
});
};
match guide.current() {
Some(step) => (
step.checkpoint.clone().map(|n| (n, step.with.clone().unwrap_or(Value::Null))),
step.label.clone(),
false,
),
None => (None, String::new(), true),
}
};
if done {
if !announce_once(world) {
return Ok(None);
}
let report = world.resource::<Guide>().report();
return Ok(Some(json!({ "done": true, "guide": report })));
}
let Some((name, args)) = 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 Some(system) = world.resource::<Checkpoints>().get(&name) else {
let known = world.resource::<Checkpoints>().names();
return Err(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(", ") }
)));
};
match world.run_system_with(system, args) {
Ok(true) => {
let mut guide = world.resource_mut::<Guide>();
guide.advance(true);
let report = guide.report();
Ok(Some(json!({ "passed": label, "guide": report })))
}
Ok(false) => Ok(None),
Err(e) => Err(BrpError {
code: error_codes::INTERNAL_ERROR,
message: format!("checkpoint `{name}` could not run: {e}"),
data: 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)>);
#[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()));
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());
}
}