use serde_json::Value;
use super::graph::{Card, CardKind, Chart, Wire};
use super::outline;
use super::palette;
const MAX_COLUMN: usize = 12;
const FIRES: &str = "on";
const READS: &str = "reads";
const SETS: &str = "sets";
const ADDS: &str = "adds";
const SPAWNS: &str = "spawn";
const ENTERS: &str = "enter";
const EXITS: &str = "exit";
const INTERACTS: &str = "use";
const JUMPS: &str = "jumps";
const SHOWS: &str = "shows";
const PLAYS: &str = "plays";
const HIDES: &str = "hides";
const ENDS: &str = "ends";
const PINS: &str = "pins";
const MOVES: &str = "moves";
#[cfg(test)]
const LABELS: &[&str] = &[
FIRES, READS, SETS, ADDS, SPAWNS, ENTERS, EXITS, INTERACTS, JUMPS, SHOWS, PLAYS, HIDES, ENDS,
PINS, MOVES,
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Ref {
Volume,
Scene,
Screen,
Entity,
}
impl Ref {
fn asset_type(self) -> Option<&'static str> {
match self {
Ref::Volume => Some("TriggerVolume"),
Ref::Scene => Some("Scene"),
Ref::Screen => Some("Screen"),
Ref::Entity => None,
}
}
fn noun(self) -> &'static str {
match self {
Ref::Volume => "volume",
Ref::Scene => "scene",
Ref::Screen => "screen",
Ref::Entity => "entity",
}
}
}
pub(crate) fn map(behaviors: &[(String, Value)], world: &[(&str, &str)]) -> Chart {
let mut build = Build {
world,
shared: shared_entities(behaviors),
..Build::default()
};
for (i, (name, args)) in behaviors.iter().enumerate() {
build.behavior(i, name, args);
}
for (i, (_, args)) in behaviors.iter().enumerate() {
build.source(i, args);
}
for (i, (_, args)) in behaviors.iter().enumerate() {
build.body(i, args);
}
build.finish()
}
fn shared_entities(behaviors: &[(String, Value)]) -> Vec<String> {
let mut counts: Vec<(String, usize)> = Vec::new();
for (_, args) in behaviors {
let mut names: Vec<String> = scan(args)
.reaches
.into_iter()
.filter(|r| r.kind == Ref::Entity)
.map(|r| r.name)
.collect();
names.sort_unstable();
names.dedup();
for name in names {
match counts.iter_mut().find(|(n, _)| *n == name) {
Some((_, seen)) => *seen += 1,
None => counts.push((name, 1)),
}
}
}
counts
.into_iter()
.filter(|(_, seen)| *seen > 1)
.map(|(name, _)| name)
.collect()
}
struct Reach {
name: String,
kind: Ref,
label: &'static str,
}
#[derive(Default)]
struct Body {
writes: Vec<(String, bool)>,
reads: Vec<String>,
spawns: bool,
reaches: Vec<Reach>,
story: bool,
}
impl Body {
fn reach(&mut self, name: Option<&str>, kind: Ref, label: &'static str) {
if let Some(name) = name.filter(|n| !n.is_empty()) {
self.reaches.push(Reach {
name: name.to_string(),
kind,
label,
});
}
}
}
#[derive(Default)]
struct Build<'a> {
cards: Vec<Card>,
wires: Vec<Wire>,
behaviors: Vec<usize>,
triggers: Vec<(String, usize)>,
variables: Vec<(String, usize)>,
assets: Vec<(String, usize)>,
world: &'a [(&'a str, &'a str)],
shared: Vec<String>,
}
impl Build<'_> {
fn behavior(&mut self, at: usize, name: &str, args: &Value) {
let mut card = card(name, scope_summary(args), CardKind::Behavior);
card.behavior = Some(at);
let card = self.push(card);
self.behaviors.push(card);
}
fn source(&mut self, at: usize, args: &Value) {
let to = self.behaviors[at];
let on = args.get("on");
let verb = on.map_or("start", palette::verb_of);
let named = on
.and_then(palette::body_of)
.and_then(Value::as_str)
.filter(|name| !name.is_empty());
match (verb, named) {
("variable", _) => {
let name = palette::body_of(on.unwrap_or(&Value::Null))
.and_then(Value::as_str)
.unwrap_or("");
let from = self.variable(name);
self.link(from, to, Some(FIRES));
}
("enter" | "exit", Some(name)) => {
let from = self.asset(name, Ref::Volume);
let label = if verb == "enter" { ENTERS } else { EXITS };
self.link(from, to, Some(label));
}
("interact", Some(name)) => {
let from = self.asset(name, Ref::Entity);
self.link(from, to, Some(INTERACTS));
}
_ => {
let from = self.trigger(&outline::source_summary(verb, on));
self.link(from, to, None);
}
}
}
fn body(&mut self, at: usize, args: &Value) {
let to = self.behaviors[at];
let body = scan(args);
for (name, adds) in &body.writes {
let var = self.variable(name);
self.link(to, var, Some(if *adds { ADDS } else { SETS }));
}
for name in &body.reads {
let var = self.variable(name);
self.link(var, to, Some(READS));
}
if body.spawns
&& let Some(spawned) = self.find(&self.triggers, "spawned")
{
self.link(to, spawned, Some(SPAWNS));
}
for reach in &body.reaches {
if reach.kind == Ref::Entity && !self.meets(&reach.name) {
continue;
}
let asset = self.asset(&reach.name, reach.kind);
self.link(to, asset, Some(reach.label));
}
if body.story {
let story = self.story();
self.link(to, story, Some(PLAYS));
}
}
fn meets(&self, name: &str) -> bool {
self.shared.iter().any(|n| n == name) || self.find(&self.assets, name).is_some()
}
fn push(&mut self, card: Card) -> usize {
self.cards.push(card);
self.cards.len() - 1
}
fn find(&self, index: &[(String, usize)], key: &str) -> Option<usize> {
index.iter().find(|(k, _)| k == key).map(|(_, i)| *i)
}
fn trigger(&mut self, caption: &str) -> usize {
if let Some(at) = self.find(&self.triggers, caption) {
return at;
}
let card = self.push(card(caption, "trigger".to_string(), CardKind::Trigger));
self.triggers.push((caption.to_string(), card));
card
}
fn variable(&mut self, name: &str) -> usize {
if let Some(at) = self.find(&self.variables, name) {
return at;
}
let card = self.push(card(name, "variable".to_string(), CardKind::Variable));
self.variables.push((name.to_string(), card));
card
}
fn asset(&mut self, name: &str, want: Ref) -> usize {
if let Some(at) = self.find(&self.assets, name) {
return at;
}
let declared = self
.world
.iter()
.find(|(n, _)| *n == name)
.map(|(_, ty)| *ty)
.filter(|ty| want.asset_type().is_none_or(|wanted| wanted == *ty));
let card = self.push(match declared {
Some(ty) => card(name, ty.to_string(), CardKind::Asset),
None => card(name, format!("missing {}", want.noun()), CardKind::Missing),
});
self.assets.push((name.to_string(), card));
card
}
fn story(&mut self) -> usize {
let declared = self
.world
.iter()
.find(|(_, ty)| *ty == "Story" || *ty == "StoryImport")
.map(|(name, _)| *name);
let name = declared.unwrap_or("story");
if let Some(at) = self.find(&self.assets, name) {
return at;
}
let card = self.push(match declared {
Some(_) => card(name, "story".to_string(), CardKind::Asset),
None => card(name, "missing story".to_string(), CardKind::Missing),
});
self.assets.push((name.to_string(), card));
card
}
fn link(&mut self, from: usize, to: usize, label: Option<&str>) {
if self.wires.iter().any(|w| w.from == from && w.to == to) {
return;
}
self.wires.push(Wire {
from,
to,
label: label.map(str::to_string),
});
}
fn order(&mut self) {
let mut from: Vec<usize> = (0..self.cards.len()).collect();
from.sort_by_key(|&i| self.cards[i].behavior.is_some());
let mut to = vec![0; self.cards.len()];
for (moved, &card) in from.iter().enumerate() {
to[card] = moved;
}
self.cards = from.iter().map(|&i| self.cards[i].clone()).collect();
for wire in &mut self.wires {
wire.from = to[wire.from];
wire.to = to[wire.to];
}
}
fn finish(mut self) -> Chart {
self.order();
let (columns, rows) = place(&mut self.cards, &self.wires);
Chart {
cards: self.cards,
wires: self.wires,
columns,
rows,
}
}
}
fn card(title: &str, detail: String, kind: CardKind) -> Card {
Card {
column: 0,
row: 0,
title: title.to_string(),
detail,
kind,
path: Vec::new(),
settles: Vec::new(),
behavior: None,
}
}
fn place(cards: &mut [Card], wires: &[Wire]) -> (usize, usize) {
for _ in 0..cards.len() {
let mut moved = false;
for w in wires {
let want = (cards[w.from].column + 1).min(MAX_COLUMN);
if cards[w.to].column < want {
cards[w.to].column = want;
moved = true;
}
}
if !moved {
break;
}
}
let mut next = [0usize; MAX_COLUMN + 1];
for card in cards.iter_mut() {
card.row = next[card.column];
next[card.column] += 1;
}
let columns = cards.iter().map(|c| c.column + 1).max().unwrap_or(0);
let rows = cards.iter().map(|c| c.row + 1).max().unwrap_or(0);
(columns, rows)
}
fn scope_summary(args: &Value) -> String {
let scope: Vec<&str> = array(args.get("scope"))
.iter()
.filter_map(Value::as_str)
.collect();
match scope.is_empty() {
true => "world-scoped".to_string(),
false => format!("per {}", scope.join(", ")),
}
}
pub(crate) fn variables_used(behaviors: &[(String, Value)]) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
for (_, args) in behaviors {
let body = scan(args);
let watched = args
.get("on")
.and_then(|v| v.get("variable"))
.and_then(Value::as_str)
.map(str::to_string);
let touched = body
.writes
.into_iter()
.map(|(name, _)| name)
.chain(body.reads)
.chain(watched);
for name in touched.filter(|n| !n.is_empty()) {
if !names.contains(&name) {
names.push(name);
}
}
}
names.sort();
names
}
fn scan(args: &Value) -> Body {
let mut body = Body::default();
scan_nodes(array(args.get("do")), &mut body);
body
}
fn scan_nodes(nodes: &[Value], body: &mut Body) {
for node in nodes {
scan_node(node, body);
}
}
fn scan_node(node: &Value, body: &mut Body) {
let verb = palette::verb_of(node);
let Some(at) = palette::body_of(node) else {
return;
};
let field = |key: &str| at.get(key);
match verb {
"set" => {
if let Some(name) = field("var").and_then(Value::as_str) {
let adds = field("add").and_then(Value::as_bool).unwrap_or(false);
body.writes.push((name.to_string(), adds));
}
if let Some(value) = field("value") {
scan_expr(value, body);
}
}
"spawn" => body.spawns = true,
"if" => {
if let Some(cond) = field("cond") {
scan_expr(cond, body);
}
for branch in ["then", "else"] {
scan_nodes(array(field(branch)), body);
}
}
"for_each" => scan_nodes(array(field("do")), body),
_ => {
scan_reaches(verb, at, body);
scan_expr(at, body);
}
}
}
fn scan_reaches(verb: &str, at: &Value, body: &mut Body) {
let name = |key: &str| at.get(key).and_then(Value::as_str);
let entity = |key: &str| at.get(key).and_then(named_of);
match verb {
"scene" => body.reach(name("scene"), Ref::Scene, JUMPS),
"screen" => body.reach(name("screen"), Ref::Screen, SHOWS),
"story" => body.story = true,
"hide" => body.reach(entity("target"), Ref::Entity, HIDES),
"show" => body.reach(entity("target"), Ref::Entity, SHOWS),
"despawn" => body.reach(entity("target"), Ref::Entity, ENDS),
"set_transform" => body.reach(entity("entity"), Ref::Entity, MOVES),
"reparent" => {
body.reach(entity("child"), Ref::Entity, PINS);
body.reach(entity("parent"), Ref::Entity, PINS);
}
_ => {}
}
}
fn named_of(value: &Value) -> Option<&str> {
(palette::verb_of(value) == "named")
.then(|| palette::body_of(value).and_then(Value::as_str))
.flatten()
}
fn scan_expr(value: &Value, body: &mut Body) {
if palette::verb_of(value) == "var"
&& let Some(name) = palette::body_of(value).and_then(Value::as_str)
{
body.reads.push(name.to_string());
return;
}
match value {
Value::Array(items) => items.iter().for_each(|v| scan_expr(v, body)),
Value::Object(map) => map.values().for_each(|v| scan_expr(v, body)),
_ => {}
}
}
fn array(value: Option<&Value>) -> &[Value] {
value
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[])
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn behaviors(entries: &[(&str, Value)]) -> Vec<(String, Value)> {
entries
.iter()
.map(|(n, a)| ((*n).to_string(), a.clone()))
.collect()
}
fn mapped(entries: &[(&str, Value)]) -> Chart {
map(&behaviors(entries), &[])
}
fn card(chart: &Chart, title: &str) -> usize {
chart
.cards
.iter()
.position(|c| c.title == title)
.unwrap_or_else(|| panic!("no `{title}` card in {:?}", titles(chart)))
}
fn titles(chart: &Chart) -> Vec<&str> {
chart.cards.iter().map(|c| c.title.as_str()).collect()
}
fn wire<'a>(chart: &'a Chart, from: &str, to: &str) -> &'a Wire {
let (a, b) = (card(chart, from), card(chart, to));
chart
.wires
.iter()
.find(|w| w.from == a && w.to == b)
.unwrap_or_else(|| panic!("no wire `{from}` -> `{to}`"))
}
#[test]
fn a_variable_joins_the_behavior_that_sets_it_to_the_one_it_fires() {
let chart = mapped(&[
(
"award",
json!({"on": "start", "do": [{"set": {"var": "score", "value": {"int": 1}}}]}),
),
("react", json!({"on": {"variable": "score"}, "do": []})),
]);
assert_eq!(
wire(&chart, "award", "score").label.as_deref(),
Some("sets")
);
assert_eq!(wire(&chart, "score", "react").label.as_deref(), Some(FIRES));
let (award, score, react) = (
&chart.cards[card(&chart, "award")],
&chart.cards[card(&chart, "score")],
&chart.cards[card(&chart, "react")],
);
assert!(award.column < score.column && score.column < react.column);
}
#[test]
fn behaviors_sharing_a_trigger_share_its_card() {
let chart = mapped(&[
("a", json!({"on": "start", "do": []})),
("b", json!({"on": "start", "do": []})),
]);
assert_eq!(titles(&chart).iter().filter(|t| **t == "start").count(), 1);
let start = card(&chart, "start");
assert_eq!(chart.wires.iter().filter(|w| w.from == start).count(), 2);
}
#[test]
fn a_behavior_card_points_back_at_its_behavior() {
let chart = mapped(&[
("first", json!({"on": "start"})),
("second", json!({"on": "tick"})),
]);
assert_eq!(chart.cards[card(&chart, "second")].behavior, Some(1));
assert_eq!(chart.cards[card(&chart, "start")].behavior, None);
}
#[test]
fn a_variable_only_read_is_still_drawn_reaching_the_behavior() {
let chart = mapped(&[(
"gate",
json!({"on": "tick", "do": [{"if": {"cond": {"gt": [{"var": "health"}, {"int": 0}]},
"then": [], "else": []}}]}),
)]);
assert_eq!(wire(&chart, "health", "gate").label.as_deref(), Some(READS));
}
#[test]
fn a_set_names_a_write_not_a_read() {
let chart = mapped(&[(
"tally",
json!({"on": "tick", "do": [{"for_each": {"query": "q", "bind": "e", "do": [
{"set": {"var": "total", "value": {"add": [{"var": "total"}, {"int": 1}]},
"add": false}},
]}}]}),
)]);
assert_eq!(
wire(&chart, "tally", "total").label.as_deref(),
Some("sets")
);
assert_eq!(wire(&chart, "total", "tally").label.as_deref(), Some(READS));
}
#[test]
fn a_cycle_settles_instead_of_marching_right() {
let chart = mapped(&[
(
"ping",
json!({"on": {"variable": "b"}, "do": [{"set": {"var": "a", "value": {"int": 1}}}]}),
),
(
"pong",
json!({"on": {"variable": "a"}, "do": [{"set": {"var": "b", "value": {"int": 1}}}]}),
),
]);
assert!(chart.columns <= MAX_COLUMN + 1);
assert!(chart.cards.iter().all(|c| c.column <= MAX_COLUMN));
}
#[test]
fn a_spawn_reaches_the_behaviors_waiting_on_it() {
let spawner = json!({"on": "start", "do": [{"spawn": {"template": "drop"}}]});
let alone = mapped(&[("drip", spawner.clone())]);
assert!(!titles(&alone).contains(&"spawned"));
let paired = mapped(&[
("drip", spawner),
(
"greet",
json!({"on": "spawned", "scope": ["Prop"], "do": []}),
),
]);
assert_eq!(
wire(&paired, "drip", "spawned").label.as_deref(),
Some(SPAWNS)
);
assert_eq!(wire(&paired, "spawned", "greet").label, None);
}
#[test]
fn a_card_says_whether_its_behavior_runs_per_entity() {
let chart = mapped(&[
("world", json!({"on": "start"})),
("each", json!({"on": "tick", "scope": ["Prop"]})),
]);
assert_eq!(chart.cards[card(&chart, "world")].detail, "world-scoped");
assert_eq!(chart.cards[card(&chart, "each")].detail, "per Prop");
}
#[test]
fn an_empty_world_maps_to_an_empty_chart() {
let chart = map(&[], &[]);
assert!(chart.cards.is_empty() && chart.wires.is_empty());
assert_eq!((chart.columns, chart.rows), (0, 0));
}
#[test]
fn every_wire_label_fits_the_gap_it_is_drawn_in() {
for label in LABELS {
assert!(
label.chars().count() <= crate::editor::behavior_chart::LABEL_CHARS,
"`{label}` is too wide to draw on a wire",
);
}
}
#[test]
fn behaviors_watching_one_volume_meet_at_its_card() {
let chart = map(
&behaviors(&[
("arrive", json!({"on": {"enter": "door_zone"}, "do": []})),
("leave", json!({"on": {"exit": "door_zone"}, "do": []})),
]),
&[("door_zone", "TriggerVolume")],
);
assert_eq!(
titles(&chart),
vec!["door_zone", "arrive", "leave"],
"one card for the volume, not one per way in",
);
assert_eq!(chart.cards[card(&chart, "door_zone")].kind, CardKind::Asset);
assert_eq!(
chart.cards[card(&chart, "door_zone")].detail,
"TriggerVolume"
);
assert_eq!(
wire(&chart, "door_zone", "arrive").label.as_deref(),
Some(ENTERS)
);
assert_eq!(
wire(&chart, "door_zone", "leave").label.as_deref(),
Some(EXITS)
);
}
#[test]
fn where_a_behavior_sends_the_world_is_a_card_of_its_own() {
let chart = map(
&behaviors(&[
(
"finish",
json!({"on": "start", "do": [{"scene": {"scene": "hub", "transition": "Cut"}}]}),
),
(
"quit",
json!({"on": "tick", "do": [
{"scene": {"scene": "hub"}},
{"screen": {"screen": "pause"}},
]}),
),
]),
&[("hub", "Scene"), ("pause", "Screen")],
);
assert_eq!(wire(&chart, "finish", "hub").label.as_deref(), Some(JUMPS));
assert_eq!(wire(&chart, "quit", "hub").label.as_deref(), Some(JUMPS));
assert_eq!(wire(&chart, "quit", "pause").label.as_deref(), Some(SHOWS));
assert_eq!(chart.cards[card(&chart, "hub")].detail, "Scene");
let hub = chart.cards[card(&chart, "hub")].column;
assert!(hub > chart.cards[card(&chart, "finish")].column);
assert!(hub > chart.cards[card(&chart, "quit")].column);
}
#[test]
fn the_worlds_story_is_the_card_everything_driving_it_meets_at() {
let driving = behaviors(&[
("open", json!({"on": "start", "do": [{"story": "start"}]})),
(
"resume",
json!({"on": "tick", "do": [{"story": "continue"}]}),
),
]);
let chart = map(&driving, &[("tale", "StoryImport")]);
assert_eq!(chart.cards[card(&chart, "tale")].detail, "story");
assert_eq!(wire(&chart, "open", "tale").label.as_deref(), Some(PLAYS));
assert_eq!(wire(&chart, "resume", "tale").label.as_deref(), Some(PLAYS));
let none = map(&driving, &[]);
assert_eq!(none.cards[card(&none, "story")].kind, CardKind::Missing);
}
#[test]
fn a_name_the_world_does_not_declare_is_drawn_as_missing() {
let chart = map(
&behaviors(&[(
"escape",
json!({"on": {"enter": "porch"}, "do": [{"scene": {"scene": "hubb"}}]}),
)]),
&[("hub", "Scene"), ("porch", "Prop")],
);
for (name, detail) in [("hubb", "missing scene"), ("porch", "missing volume")] {
let at = &chart.cards[card(&chart, name)];
assert_eq!(at.kind, CardKind::Missing, "{name}");
assert_eq!(at.detail, detail, "{name}");
}
assert_eq!(wire(&chart, "escape", "hubb").label.as_deref(), Some(JUMPS));
}
#[test]
fn an_entity_is_a_card_once_a_second_behavior_reaches_it() {
let shut = (
"shut",
json!({"on": "start", "do": [{"hide": {"target": {"named": "door"}}}]}),
);
let alone = map(&behaviors(std::slice::from_ref(&shut)), &[("door", "Prop")]);
assert!(!titles(&alone).contains(&"door"), "{:?}", titles(&alone));
let both = map(
&behaviors(&[
shut,
(
"open",
json!({"on": "tick", "do": [
{"show": {"target": {"named": "door"}}},
{"set_transform": {"entity": {"named": "door"}, "scale": null}},
{"despawn": {"target": "self"}},
]}),
),
]),
&[("door", "Prop")],
);
assert_eq!(both.cards[card(&both, "door")].detail, "Prop");
assert_eq!(wire(&both, "shut", "door").label.as_deref(), Some(HIDES));
assert_eq!(wire(&both, "open", "door").label.as_deref(), Some(SHOWS));
assert_eq!(both.cards.len(), 5, "{:?}", titles(&both));
}
#[test]
fn a_body_reaching_what_fires_it_joins_that_card() {
let chart = map(
&behaviors(&[(
"toggle",
json!({"on": {"interact": "lamp"}, "do": [{"hide": {"target": {"named": "lamp"}}}]}),
)]),
&[("lamp", "Prop")],
);
assert_eq!(
wire(&chart, "lamp", "toggle").label.as_deref(),
Some(INTERACTS)
);
assert_eq!(wire(&chart, "toggle", "lamp").label.as_deref(), Some(HIDES));
}
#[test]
fn the_middlemen_come_before_the_behaviors_they_join() {
let world: Vec<(&str, Value)> = (0..8)
.map(|_| ("watch", json!({"on": {"variable": "score"}, "do": []})))
.collect();
let chart = mapped(&world);
let first = chart
.cards
.iter()
.position(|c| c.behavior.is_some())
.expect("the world has behaviors");
assert!(
chart.cards[first..].iter().all(|c| c.behavior.is_some()),
"{:?}",
chart.cards.iter().map(|c| c.kind).collect::<Vec<_>>(),
);
assert_eq!(wire(&chart, "score", "watch").label.as_deref(), Some(FIRES));
}
#[test]
fn cards_sharing_a_column_take_their_own_rows() {
let chart = mapped(&[
("a", json!({"on": "start"})),
("b", json!({"on": "start"})),
("c", json!({"on": "start"})),
]);
let mut seen: Vec<(usize, usize)> = chart.cards.iter().map(|c| (c.column, c.row)).collect();
seen.sort_unstable();
seen.dedup();
assert_eq!(seen.len(), chart.cards.len(), "no two cards share a place");
}
}