use concinnity_cook::authoring::flow::{FlowEdge, FlowGraph, Move, Place, PlaceKind};
use super::Entries;
use super::contents;
use crate::editor::asset_handle::AssetHandle;
use crate::editor::behavior::chart::LABEL_CHARS;
use crate::editor::behavior::graph::{Card, CardKind, Chart, Wire};
const MAX_COLUMN: usize = 12;
const WORLD: &str = "world";
pub(super) fn chart(entries: &Entries, graph: &FlowGraph) -> Chart {
let mut build = Build {
entries,
graph,
cards: Vec::new(),
wires: Vec::new(),
pinned: Vec::new(),
by_place: Vec::new(),
by_name: Vec::new(),
world: None,
};
build.places();
build.moves();
build.finish()
}
struct Build<'a> {
entries: &'a Entries,
graph: &'a FlowGraph,
cards: Vec<Card>,
wires: Vec<Wire>,
pinned: Vec<bool>,
by_place: Vec<(String, usize)>,
by_name: Vec<(String, usize)>,
world: Option<usize>,
}
impl Build<'_> {
fn places(&mut self) {
let graph = self.graph;
let starts = graph.entries();
for place in &starts {
self.place(place, true);
}
for place in &graph.places {
if !starts.iter().any(|start| start.id == place.id) {
self.place(place, false);
}
}
}
fn moves(&mut self) {
let graph = self.graph;
for edge in &graph.edges {
let Some(to) = self.destination(edge) else {
continue;
};
let from = self.source(edge);
self.link(from, to, label(edge));
}
}
fn place(&mut self, place: &Place, start: bool) -> usize {
let held = contents::held_by(self.entries.assets(), &place.id);
let kind = noun(place.kind);
let detail = match held.is_empty() {
true => kind.to_string(),
false => format!("{kind}, {held}"),
};
let handle = self.entries.handle(place);
let at = self.push(card(&place.id, detail, CardKind::Asset, handle), start);
self.by_place.push((place.id.clone(), at));
at
}
fn source(&mut self, edge: &FlowEdge) -> usize {
let Some(name) = edge.from.as_deref() else {
return self.world();
};
match find(&self.by_place, name) {
Some(at) => at,
None => self.missing(name, noun(PlaceKind::Screen)),
}
}
fn destination(&mut self, edge: &FlowEdge) -> Option<usize> {
let graph = self.graph;
if let Some(place) = graph.destination(edge) {
return find(&self.by_place, &place.id);
}
match &edge.action {
Move::Scene(name) => Some(self.missing(name, noun(PlaceKind::Scene))),
Move::Show(name) | Move::Push(name) | Move::Toggle(name) => {
Some(self.missing(name, noun(PlaceKind::Screen)))
}
Move::Story => self.story(),
Move::Back | Move::Quit => None,
}
}
fn story(&mut self) -> Option<usize> {
let graph = self.graph;
match graph.places.iter().find(|p| p.kind == PlaceKind::Story) {
Some(story) => find(&self.by_place, &story.id),
None => Some(self.missing(noun(PlaceKind::Story), noun(PlaceKind::Story))),
}
}
fn missing(&mut self, name: &str, noun: &str) -> usize {
if let Some(at) = find(&self.by_name, name) {
return at;
}
let at = self.push(
card(name, format!("missing {noun}"), CardKind::Missing, None),
false,
);
self.by_name.push((name.to_string(), at));
at
}
fn world(&mut self) -> usize {
if let Some(at) = self.world {
return at;
}
let held = contents::unplaced(self.entries.assets(), &self.graph.places);
let at = self.push(card(WORLD, held, CardKind::Asset, None), false);
self.world = Some(at);
at
}
fn push(&mut self, card: Card, pinned: bool) -> usize {
self.cards.push(card);
self.pinned.push(pinned);
self.cards.len() - 1
}
fn link(&mut self, from: usize, to: usize, label: String) {
let drawn = |w: &Wire| w.from == from && w.to == to && w.label.as_deref() == Some(&*label);
if from == to || self.wires.iter().any(drawn) {
return;
}
self.wires.push(Wire {
from,
to,
label: Some(label),
});
}
fn finish(mut self) -> Chart {
if self.cards.is_empty() {
self.world();
}
let (columns, rows) = lay_out(&mut self.cards, &self.wires, &self.pinned);
Chart {
cards: self.cards,
wires: self.wires,
columns,
rows,
}
}
}
fn card(title: &str, detail: String, kind: CardKind, handle: Option<AssetHandle>) -> Card {
Card {
column: 0,
row: 0,
title: title.to_string(),
detail,
kind,
path: Vec::new(),
settles: Vec::new(),
behavior: None,
handle,
}
}
fn noun(kind: PlaceKind) -> &'static str {
match kind {
PlaceKind::Scene => "scene",
PlaceKind::Screen => "screen",
PlaceKind::Story => "story",
}
}
fn label(edge: &FlowEdge) -> String {
edge.label
.as_deref()
.filter(|name| name.chars().count() <= LABEL_CHARS)
.unwrap_or_else(|| edge.action.verb())
.to_string()
}
fn find(index: &[(String, usize)], key: &str) -> Option<usize> {
index
.iter()
.find(|(name, _)| name == key)
.map(|(_, at)| *at)
}
fn lay_out(cards: &mut [Card], wires: &[Wire], pinned: &[bool]) -> (usize, usize) {
for _ in 0..cards.len() {
let mut moved = false;
for wire in wires.iter().filter(|w| !pinned[w.to]) {
let want = (cards[wire.from].column + 1).min(MAX_COLUMN);
if cards[wire.to].column < want {
cards[wire.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)
}
#[cfg(test)]
mod tests {
use concinnity_cook::authoring::flow::flow_graph;
use concinnity_cook::authoring::registry::RegisteredType;
use concinnity_cook::authoring::world::WorldJsonlAsset;
use serde_json::{Value, json};
use super::*;
use crate::editor::entry_list::EntryList;
fn asset(ty: RegisteredType, id: &str, args: Value) -> WorldJsonlAsset {
WorldJsonlAsset {
id: id.to_string(),
asset_type: ty,
args,
}
}
fn mapped(assets: Vec<WorldJsonlAsset>) -> Chart {
let mut list = EntryList::default();
let entries = Entries::new(assets.into_iter().map(|a| (list.push(Value::Null), a)));
chart(&entries, &flow_graph(entries.assets()))
}
fn at<'a>(chart: &'a Chart, title: &str) -> &'a Card {
chart
.cards
.iter()
.find(|card| card.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 index = |title| chart.cards.iter().position(|c| c.title == title).unwrap();
let (from, to) = (index(from), index(to));
chart
.wires
.iter()
.find(|w| w.from == from && w.to == to)
.unwrap_or_else(|| panic!("no wire between those cards: {:?}", chart.wires))
}
fn menu(items: Value) -> WorldJsonlAsset {
asset(RegisteredType::MainMenu, "main", json!({"items": items}))
}
#[test]
fn a_button_draws_an_arrow_from_its_menu_to_the_scene_it_opens() {
let chart = mapped(vec![
asset(RegisteredType::Scene, "bistro", json!({})),
asset(
RegisteredType::MainMenu,
"main",
json!({"initial": true, "items": [{"label": "Start", "action": {"scene": "bistro"}}]}),
),
]);
assert_eq!(at(&chart, "main").column, 0);
assert_eq!(at(&chart, "bistro").column, 1);
assert_eq!(
wire(&chart, "main", "bistro").label.as_deref(),
Some("Start")
);
}
#[test]
fn a_wire_says_what_the_author_called_the_move_unless_it_is_too_wide() {
let chart = mapped(vec![menu(json!([
{"label": "Start", "action": {"scene": "bistro"}},
{"label": "Settings", "action": "settings"},
]))]);
let labels: Vec<&str> = chart
.wires
.iter()
.filter_map(|w| w.label.as_deref())
.collect();
assert_eq!(labels, ["Start", "show"]);
}
#[test]
fn a_move_onto_a_place_the_world_does_not_declare_says_what_is_missing() {
let chart = mapped(vec![menu(json!([
{"label": "Start", "action": {"scene": "typo"}},
{"label": "Help", "action": {"push": "typo"}},
]))]);
assert_eq!(at(&chart, "typo").kind, CardKind::Missing);
assert_eq!(at(&chart, "typo").detail, "missing scene");
assert_eq!(at(&chart, "typo").handle, None);
assert_eq!(chart.cards.len(), 2);
}
#[test]
fn a_move_from_no_place_leaves_from_the_world() {
let chart = mapped(vec![
asset(RegisteredType::Screen, "pause", json!({})),
asset(
RegisteredType::KeyBinding,
"esc",
json!({"key": "Escape", "action": {"toggle": "pause"}}),
),
]);
assert_eq!(
wire(&chart, "world", "pause").label.as_deref(),
Some("toggle")
);
assert_eq!(at(&chart, "world").handle, None);
}
#[test]
fn a_story_move_meets_at_the_story_the_world_declares() {
let teller = asset(
RegisteredType::Behavior,
"teller",
json!({"do": [{"story": {"advance": {}}}]}),
);
let told = mapped(vec![
asset(
RegisteredType::StoryImport,
"tale",
json!({"source": "t.md"}),
),
teller.clone(),
]);
assert_eq!(wire(&told, "world", "tale").label.as_deref(), Some("story"));
let untold = mapped(vec![teller]);
assert_eq!(at(&untold, "story").detail, "missing story");
}
#[test]
fn a_move_reaching_no_place_draws_no_arrow() {
let chart = mapped(vec![menu(json!([
{"label": "Back", "action": "hide"},
{"label": "Quit", "action": "quit"},
]))]);
assert_eq!(titles(&chart), ["main"]);
assert!(chart.wires.is_empty());
}
#[test]
fn a_move_back_to_where_it_started_draws_no_arrow() {
let chart = mapped(vec![
asset(RegisteredType::Screen, "pause", json!({})),
asset(
RegisteredType::HitRegion,
"close",
json!({"screen": "pause", "action": {"toggle": "pause"}}),
),
]);
assert_eq!(at(&chart, "pause").column, 0);
assert!(chart.wires.is_empty());
}
#[test]
fn the_place_the_world_starts_in_keeps_the_leftmost_column() {
let chart = mapped(vec![
asset(RegisteredType::Screen, "menu", json!({"initial": true})),
asset(RegisteredType::Scene, "level", json!({})),
asset(
RegisteredType::HitRegion,
"play",
json!({"screen": "menu", "action": {"scene": "level"}}),
),
asset(
RegisteredType::HitRegion,
"give_up",
json!({"screen": "level", "action": {"show": "menu"}}),
),
]);
assert_eq!(at(&chart, "menu").column, 0);
assert_eq!(at(&chart, "level").column, 1);
}
#[test]
fn a_cycle_settles_instead_of_marching_right() {
let mut world = Vec::new();
for (screen, next) in [("a", "b"), ("b", "c"), ("c", "a")] {
world.push(asset(RegisteredType::Screen, screen, json!({})));
world.push(asset(
RegisteredType::HitRegion,
&format!("{screen}_go"),
json!({"screen": screen, "action": {"show": next}}),
));
}
let chart = mapped(world);
assert_eq!(chart.cards.len(), 3);
assert!(chart.columns <= MAX_COLUMN + 1, "{}", chart.columns);
}
#[test]
fn one_place_reached_twice_the_same_way_is_drawn_once() {
let chart = mapped(vec![
asset(RegisteredType::Scene, "level", json!({})),
menu(json!([
{"label": "Play", "action": {"scene": "level"}},
{"label": "Play", "action": {"scene": "level"}},
{"label": "Again", "action": {"show": "level"}},
])),
]);
assert_eq!(chart.wires.len(), 2);
}
#[test]
fn every_verb_fits_the_gap_it_is_drawn_in() {
let moves = [
Move::Scene(String::new()),
Move::Show(String::new()),
Move::Push(String::new()),
Move::Toggle(String::new()),
Move::Back,
Move::Story,
Move::Quit,
];
for action in moves {
let verb = action.verb();
assert!(
verb.chars().count() <= LABEL_CHARS,
"`{verb}` is too wide to draw on a wire",
);
}
}
#[test]
fn cards_stack_down_the_column_they_share() {
let chart = mapped(vec![
asset(RegisteredType::Scene, "a", json!({})),
asset(RegisteredType::Scene, "b", json!({})),
asset(RegisteredType::Screen, "c", json!({})),
]);
let places: Vec<(usize, usize)> = chart.cards.iter().map(|c| (c.column, c.row)).collect();
assert_eq!(places, [(0, 0), (0, 1), (0, 2)]);
assert_eq!((chart.columns, chart.rows), (1, 3));
}
}