use super::*;
use crate::components::{
Screen, Story, StoryChoice, StoryNode, StoryPage, StoryScaffold, StorySpeaker,
};
use crate::ecs::SYSTEMS;
use crate::ecs::World;
use crate::ecs::asset_id::intern;
use crate::ecs::{AudioClipHandle, TextureHandle};
fn page(text: &str) -> StoryPage {
StoryPage {
text: text.to_string(),
..Default::default()
}
}
fn label_named(name: &str) -> TextLabel {
TextLabel {
asset_id: intern(name),
..Default::default()
}
}
fn sprite_named(name: &str) -> Sprite {
Sprite {
asset_id: intern(name),
..Default::default()
}
}
fn scaffold() -> StoryScaffold {
StoryScaffold {
screen: Some(intern("s_stage")),
ending: Some(intern("s_ending")),
bg: Some(intern("s_stage_bg")),
left: Some(intern("s_stage_left")),
center: Some(intern("s_stage_center")),
right: Some(intern("s_stage_right")),
dialog_box: Some(intern("s_stage_box")),
name_label: Some(intern("s_stage_name")),
text_label: Some(intern("s_stage_text")),
option_boxes: vec![intern("s_stage_opt0_box")],
options: vec![intern("s_stage_opt0_lbl")],
start_label: None,
quit_label: None,
continue_label: None,
title: None,
load_label: None,
pause: None,
settings: None,
settings_label: None,
advance_marker: Some(intern("s_stage_marker")),
log_label: Some(intern("s_stage_qlog_lbl")),
auto_label: Some(intern("s_stage_qauto_lbl")),
skip_label: Some(intern("s_stage_qskip_lbl")),
save_label: Some(intern("s_stage_qsave_lbl")),
overlay_dim: Some(intern("s_stage_dim")),
backlog_label: Some(intern("s_stage_history")),
slot_title: Some(intern("s_stage_slot_title")),
slot_boxes: vec![intern("s_stage_slot0_box")],
slot_labels: vec![intern("s_stage_slot0_lbl")],
}
}
fn add_stage_furniture(world: &mut World) {
for sprite in [
"s_stage_bg",
"s_stage_left",
"s_stage_center",
"s_stage_right",
"s_stage_box",
"s_stage_opt0_box",
"s_stage_marker",
"s_stage_dim",
"s_stage_slot0_box",
] {
world.add_component(Sprite {
screen: Some(intern("s_stage")),
..sprite_named(sprite)
});
}
for label in [
"s_stage_name",
"s_stage_text",
"s_stage_opt0_lbl",
"s_stage_qlog_lbl",
"s_stage_qauto_lbl",
"s_stage_qskip_lbl",
"s_stage_qsave_lbl",
"s_stage_history",
"s_stage_slot_title",
"s_stage_slot0_lbl",
] {
world.add_component(TextLabel {
screen: Some(intern("s_stage")),
..label_named(label)
});
}
}
fn story_world(story: Story) -> World {
let mut world = World::new();
let mut story = story;
story.asset_id = intern("s");
story.scaffold = scaffold();
world.add_component(story);
for screen in ["s_stage", "s_ending"] {
world.add_component(Screen {
asset_id: intern(screen),
initial: screen == "s_stage",
fade_in_secs: 0.0,
..Default::default()
});
}
add_stage_furniture(&mut world);
world
}
fn multi_slot_world(story: Story, rows: usize) -> World {
let mut world = World::new();
let mut story = story;
story.asset_id = intern("s");
let mut sc = scaffold();
sc.slot_boxes = (0..rows)
.map(|i| intern(&format!("s_stage_slot{i}_box")))
.collect();
sc.slot_labels = (0..rows)
.map(|i| intern(&format!("s_stage_slot{i}_lbl")))
.collect();
story.scaffold = sc;
world.add_component(story);
for screen in ["s_stage", "s_ending"] {
world.add_component(Screen {
asset_id: intern(screen),
initial: screen == "s_stage",
fade_in_secs: 0.0,
..Default::default()
});
}
add_stage_furniture(&mut world);
for i in 1..rows {
world.add_component(Sprite {
screen: Some(intern("s_stage")),
..sprite_named(&format!("s_stage_slot{i}_box"))
});
world.add_component(TextLabel {
screen: Some(intern("s_stage")),
..label_named(&format!("s_stage_slot{i}_lbl"))
});
}
world
}
fn title_menu_world(story: Story) -> World {
let mut world = World::new();
let mut story = story;
story.asset_id = intern("s");
let mut sc = scaffold();
sc.title = Some(intern("s_title"));
sc.start_label = Some(intern("s_title_start_lbl"));
sc.continue_label = Some(intern("s_title_continue_lbl"));
sc.load_label = Some(intern("s_title_load_lbl"));
sc.quit_label = Some(intern("s_title_quit_lbl"));
story.scaffold = sc;
world.add_component(story);
for (screen, initial) in [("s_title", true), ("s_stage", false), ("s_ending", false)] {
world.add_component(Screen {
asset_id: intern(screen),
initial,
fade_in_secs: 0.0,
..Default::default()
});
}
add_stage_furniture(&mut world);
for (name, y, text) in [
("s_title_start_lbl", 100.0, "Start"),
("s_title_continue_lbl", 200.0, "Continue"),
("s_title_load_lbl", 300.0, "Load"),
("s_title_quit_lbl", 400.0, "Quit"),
] {
world.add_component(TextLabel {
screen: Some(intern("s_title")),
content: text.to_string(),
y,
..label_named(name)
});
}
world
}
fn story_world_with_pause(story: Story) -> World {
let mut world = World::new();
let mut story = story;
story.asset_id = intern("s");
let mut sc = scaffold();
sc.pause = Some(intern("s_pause"));
sc.settings = Some(intern("s_settings"));
sc.title = Some(intern("s_title"));
sc.start_label = Some(intern("s_title_start_lbl"));
sc.quit_label = Some(intern("s_title_quit_lbl"));
sc.settings_label = Some(intern("s_title_settings_lbl"));
story.scaffold = sc;
world.add_component(story);
for (screen, initial) in [
("s_stage", true),
("s_ending", false),
("s_pause", false),
("s_settings", false),
("s_title", false),
] {
world.add_component(Screen {
asset_id: intern(screen),
initial,
fade_in_secs: 0.0,
..Default::default()
});
}
for (name, screen) in [
("s_pause_dim", "s_pause"),
("s_settings_dim", "s_settings"),
("s_title_bg", "s_title"),
] {
world.add_component(Sprite {
screen: Some(intern(screen)),
..sprite_named(name)
});
}
for lbl in [
"s_title_start_lbl",
"s_title_quit_lbl",
"s_title_settings_lbl",
] {
world.add_component(TextLabel {
screen: Some(intern("s_title")),
..label_named(lbl)
});
}
add_stage_furniture(&mut world);
world
}
fn sprite_visible(world: &World, name: &str) -> bool {
let id = intern(name);
world
.query::<Sprite>()
.find(|s| s.asset_id == id)
.map(|s| s.visible)
.unwrap_or(false)
}
fn label_content(world: &World, name: &str) -> String {
let id = intern(name);
world
.query::<TextLabel>()
.find(|l| l.asset_id == id)
.map(|l| l.content.clone())
.unwrap_or_default()
}
fn label_y(world: &World, name: &str) -> f32 {
let id = intern(name);
world
.query::<TextLabel>()
.find(|l| l.asset_id == id)
.map(|l| l.y)
.unwrap_or_default()
}
fn label_color(world: &World, name: &str) -> [f32; 3] {
let id = intern(name);
world
.query::<TextLabel>()
.find(|l| l.asset_id == id)
.map(|l| l.color)
.unwrap_or_default()
}
fn two_page_story() -> Story {
Story {
title: "T".to_string(),
text_speed: 0.0,
nodes: vec![StoryNode {
slug: "a".to_string(),
pages: vec![
StoryPage {
speaker: Some(StorySpeaker {
name: "Ayame".to_string(),
color: [1.0, 0.85, 0.8],
}),
..page("First page.")
},
page("Second page."),
],
..Default::default()
}],
..Default::default()
}
}
#[test]
fn initial_stage_view_auto_starts() {
let mut world = story_world(two_page_story());
world.start(SYSTEMS).unwrap();
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "First page.");
assert_eq!(label_content(&world, "s_stage_name"), "Ayame");
}
#[test]
fn advance_walks_pages_to_the_ending() {
let mut world = story_world(two_page_story());
world.start(SYSTEMS).unwrap();
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Second page.");
assert_eq!(label_content(&world, "s_stage_name"), "");
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Second page.");
}
#[test]
fn choices_fill_buttons_and_choose_jumps() {
let story = Story {
title: "T".to_string(),
text_speed: 0.0,
nodes: vec![
StoryNode {
slug: "a".to_string(),
pages: vec![page("Pick.")],
choices: vec![StoryChoice {
label: "Go".to_string(),
target: 1,
condition: None,
}],
..Default::default()
},
StoryNode {
slug: "b".to_string(),
pages: vec![page("Picked.")],
..Default::default()
},
],
..Default::default()
};
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Choose(0));
world.step();
assert_eq!(label_content(&world, "s_stage_opt0_lbl"), "Go");
assert_eq!(label_content(&world, "s_stage_text"), "");
let opt_box = intern("s_stage_opt0_box");
let shown = world
.query::<Sprite>()
.find(|s| s.asset_id == opt_box)
.unwrap();
assert!(shown.visible);
assert!(shown.tint[3] > 0.0, "occupied slot's box is opaque");
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "");
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Choose(0));
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Picked.");
let alpha = world
.query::<Sprite>()
.find(|s| s.asset_id == opt_box)
.unwrap()
.tint[3];
assert_eq!(alpha, 0.0);
}
#[test]
fn stage_dressing_applies_to_sprites() {
let mut story = two_page_story();
story.nodes[0].pages[0].stage = StoryStage {
bg: Some(StoryImage {
texture: TextureHandle(intern("s_img0").0),
x: 0.0,
y: 0.0,
width: 1280.0,
height: 720.0,
}),
center: Some(StoryImage {
texture: TextureHandle(intern("s_img1").0),
x: 412.0,
y: 20.0,
width: 456.0,
height: 700.0,
}),
..Default::default()
};
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
world.step();
let bg = intern("s_stage_bg");
let sprite = world.query::<Sprite>().find(|s| s.asset_id == bg).unwrap();
assert_eq!(sprite.texture, Some(TextureHandle(intern("s_img0").0)));
let center = intern("s_stage_center");
let sprite = world
.query::<Sprite>()
.find(|s| s.asset_id == center)
.unwrap();
assert!(sprite.visible);
assert_eq!(sprite.width, 456.0);
assert_eq!(sprite.y, 20.0);
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
let sprite = world
.query::<Sprite>()
.find(|s| s.asset_id == center)
.unwrap();
assert_eq!(sprite.tint[3], 0.0);
let sprite = world.query::<Sprite>().find(|s| s.asset_id == bg).unwrap();
assert_eq!(sprite.texture, None);
}
#[test]
fn page_audio_sends_play_cues() {
let mut story = two_page_story();
story.nodes[0].pages[0].music = Some(AudioClipHandle(0));
story.nodes[0].pages[0].sounds = vec![AudioClipHandle(1)];
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
world.step();
let mut cursor = crate::ecs::EventCursor::default();
let cues: Vec<PlayCue> = world
.events_mut::<PlayCue>()
.read(&mut cursor)
.copied()
.collect();
assert_eq!(cues.len(), 2);
assert_eq!(cues[0].clip, AudioClipHandle(0));
assert_eq!(cues[0].kind, CueKind::Music);
assert_eq!(cues[1].kind, CueKind::Sound);
}
#[test]
fn typewriter_reveals_and_advance_completes() {
let mut story = two_page_story();
story.text_speed = 0.0001;
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "");
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "First page.");
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "");
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Second page.");
}
#[test]
fn ops_raise_flags_and_gates_redirect() {
use crate::components::{StoryGate, StoryOp};
let story = Story {
title: "T".to_string(),
text_speed: 0.0,
nodes: vec![
StoryNode {
slug: "a".to_string(),
pages: vec![
StoryPage {
ops: vec![StoryOp {
name: "asked".to_string(),
value: 1,
add: false,
}],
..page("Intro.")
},
StoryPage {
gates: vec![StoryGate {
name: "asked".to_string(),
op: StoryCompareOp::Ne,
value: 0,
target: 1,
}],
..page("Skipped.")
},
],
..Default::default()
},
StoryNode {
slug: "b".to_string(),
pages: vec![page("Landed.")],
..Default::default()
},
],
..Default::default()
};
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Intro.");
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Landed.");
}
#[test]
fn gated_choices_filter_and_remap() {
use crate::components::StoryCondition;
let story = Story {
title: "T".to_string(),
text_speed: 0.0,
nodes: vec![
StoryNode {
slug: "a".to_string(),
pages: vec![page("Pick.")],
choices: vec![
StoryChoice {
label: "Secret".to_string(),
target: 1,
condition: Some(StoryCondition {
name: "secret".to_string(),
op: StoryCompareOp::Ne,
value: 0,
}),
},
StoryChoice {
label: "Plain".to_string(),
target: 2,
condition: None,
},
],
..Default::default()
},
StoryNode {
slug: "hidden".to_string(),
pages: vec![page("Never.")],
..Default::default()
},
StoryNode {
slug: "plain".to_string(),
pages: vec![page("Plainly.")],
..Default::default()
},
],
..Default::default()
};
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
assert_eq!(label_content(&world, "s_stage_opt0_lbl"), "Plain");
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Choose(0));
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Plainly.");
}
#[test]
fn menu_gates_redirect_past_the_menu() {
use crate::components::{StoryGate, StoryOp};
let story = Story {
title: "T".to_string(),
text_speed: 0.0,
nodes: vec![
StoryNode {
slug: "a".to_string(),
pages: vec![StoryPage {
ops: vec![StoryOp {
name: "skip".to_string(),
value: 1,
add: false,
}],
..page("Once.")
}],
choices: vec![StoryChoice {
label: "Never shown".to_string(),
target: 0,
condition: None,
}],
choice_gates: vec![StoryGate {
name: "skip".to_string(),
op: StoryCompareOp::Ne,
value: 0,
target: 1,
}],
..Default::default()
},
StoryNode {
slug: "b".to_string(),
pages: vec![page("Landed.")],
..Default::default()
},
],
..Default::default()
};
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Landed.");
assert_eq!(label_content(&world, "s_stage_opt0_lbl"), "");
}
#[test]
fn story_save_round_trips() {
let dir = tempfile::tempdir().unwrap();
let auto = save_file(dir.path());
assert!(read_save(&auto).is_none());
let save = StorySave {
slug: "meadow".to_string(),
page: 2,
vars: [("asked".to_string(), 1), ("gold".to_string(), 7)]
.into_iter()
.collect(),
};
write_save(&auto, &save).unwrap();
let back = read_save(&auto).expect("save exists");
assert_eq!(back.slug, "meadow");
assert_eq!(back.page, 2);
assert_eq!(back.vars.get("gold"), Some(&7));
std::fs::write(&auto, b"not cbor").unwrap();
assert!(read_save(&auto).is_none());
let slot = slot_file(dir.path(), 1);
write_save(&slot, &save).unwrap();
assert_eq!(read_save(&slot).unwrap().slug, "meadow");
}
#[test]
fn continue_without_a_save_starts_fresh() {
let mut world = story_world(two_page_story());
world.start(SYSTEMS).unwrap();
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Continue);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "First page.");
}
#[test]
fn jump_pages_target_their_node() {
let story = Story {
title: "T".to_string(),
text_speed: 0.0,
nodes: vec![
StoryNode {
slug: "a".to_string(),
pages: vec![StoryPage {
jump: Some(2),
..page("Jump away.")
}],
..Default::default()
},
StoryNode {
slug: "skipped".to_string(),
pages: vec![page("Never seen.")],
..Default::default()
},
StoryNode {
slug: "c".to_string(),
pages: vec![page("Landed.")],
..Default::default()
},
],
..Default::default()
};
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Landed.");
}
#[test]
fn reload_swaps_the_graph_in_place() {
let mut world = story_world(two_page_story());
world.start(SYSTEMS).unwrap();
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Second page.");
let mut edited = two_page_story();
edited.nodes[0].pages[1] = page("Second page, revised.");
edited.scaffold = scaffold();
world
.events_mut::<StoryReload>()
.send(StoryReload { story: edited });
world.step();
assert_eq!(
label_content(&world, "s_stage_text"),
"Second page, revised."
);
let mut foreign = two_page_story();
foreign.nodes[0].pages[1] = page("Not ours.");
world
.events_mut::<StoryReload>()
.send(StoryReload { story: foreign });
world.step();
assert_eq!(
label_content(&world, "s_stage_text"),
"Second page, revised."
);
}
#[test]
fn reload_with_the_node_deleted_restarts() {
let mut world = story_world(two_page_story());
world.start(SYSTEMS).unwrap();
world.step();
let mut edited = two_page_story();
edited.nodes[0].slug = "renamed".to_string();
edited.nodes[0].pages[0] = page("Fresh start.");
edited.scaffold = scaffold();
world
.events_mut::<StoryReload>()
.send(StoryReload { story: edited });
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Fresh start.");
}
#[test]
fn reload_refreshes_an_open_menu() {
let choice_story = |label: &str| Story {
title: "T".to_string(),
text_speed: 0.0,
nodes: vec![
StoryNode {
slug: "a".to_string(),
pages: vec![page("Pick.")],
choices: vec![StoryChoice {
label: label.to_string(),
target: 1,
condition: None,
}],
..Default::default()
},
StoryNode {
slug: "b".to_string(),
pages: vec![page("Picked.")],
..Default::default()
},
],
..Default::default()
};
let mut world = story_world(choice_story("Go"));
world.start(SYSTEMS).unwrap();
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
assert_eq!(label_content(&world, "s_stage_opt0_lbl"), "Go");
let mut edited = choice_story("Go north");
edited.scaffold = scaffold();
world
.events_mut::<StoryReload>()
.send(StoryReload { story: edited });
world.step();
assert_eq!(label_content(&world, "s_stage_opt0_lbl"), "Go north");
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "");
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Choose(0));
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Picked.");
}
#[test]
fn numeric_ops_accumulate_and_comparisons_gate() {
use crate::components::{StoryGate, StoryOp};
let add_trip = StoryOp {
name: "trips".to_string(),
value: 1,
add: true,
};
let story = Story {
title: "T".to_string(),
text_speed: 0.0,
nodes: vec![
StoryNode {
slug: "a".to_string(),
pages: vec![
StoryPage {
ops: vec![add_trip.clone()],
..page("One.")
},
StoryPage {
ops: vec![add_trip.clone()],
..page("Two.")
},
StoryPage {
gates: vec![StoryGate {
name: "trips".to_string(),
op: StoryCompareOp::Ge,
value: 2,
target: 1,
}],
..page("Never shown.")
},
],
..Default::default()
},
StoryNode {
slug: "counted".to_string(),
pages: vec![page("Counted.")],
..Default::default()
},
],
..Default::default()
};
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Two.");
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Counted.");
}
#[test]
fn quick_row_toggles_and_suppresses_the_same_click_advance() {
let mut world = story_world(two_page_story());
world.start(SYSTEMS).unwrap();
world.step();
assert_eq!(label_content(&world, "s_stage_qauto_lbl"), "Auto");
assert_eq!(label_content(&world, "s_stage_qsave_lbl"), "Save");
world
.events_mut::<StoryCommand>()
.send(StoryCommand::ToggleAuto);
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "First page.");
let auto_id = intern("s_stage_qauto_lbl");
let color = world
.query::<TextLabel>()
.find(|l| l.asset_id == auto_id)
.unwrap()
.color;
assert_eq!(color, QUICK_ACTIVE);
}
#[test]
fn backlog_lists_history_and_a_click_dismisses() {
let mut world = story_world(two_page_story());
world.start(SYSTEMS).unwrap();
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Second page.");
world
.events_mut::<StoryCommand>()
.send(StoryCommand::ToggleLog);
world.step();
let history = label_content(&world, "s_stage_history");
assert!(history.contains("Ayame: First page."));
assert!(history.contains("Second page."));
assert_eq!(label_content(&world, "s_stage_text"), "");
assert_eq!(label_content(&world, "s_stage_qauto_lbl"), "");
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
assert_eq!(label_content(&world, "s_stage_history"), "");
assert_eq!(label_content(&world, "s_stage_text"), "Second page.");
}
#[test]
fn slot_summaries_and_slot_presence() {
let dir = tempfile::tempdir().unwrap();
let mut story = two_page_story();
story.save_key = "q".to_string();
story.scaffold = scaffold();
let mut sys = StorySystem::new(story);
sys.save_dir = Some(dir.path().to_path_buf());
sys.ids = StageIds::from_scaffold(&scaffold());
assert!(!sys.any_slot_save());
assert!(sys.slot_summary(0).contains("empty"));
let save = StorySave {
slug: "a".to_string(),
page: 1,
vars: BTreeMap::new(),
};
write_save(&slot_file(dir.path(), 0), &save).unwrap();
assert!(sys.any_slot_save());
let summary = sys.slot_summary(0);
assert!(summary.contains("a"), "{summary}");
assert!(summary.contains("page 2"), "{summary}");
}
#[test]
fn save_overlay_writes_and_load_resumes() {
let dir = tempfile::tempdir().unwrap();
let mut story = two_page_story();
story.save_key = "slot_test".to_string();
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
for system in world.systems_mut() {
if let Some(s) = system.downcast_mut::<StorySystem>() {
s.save_dir = Some(dir.path().to_path_buf());
}
}
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Second page.");
world
.events_mut::<StoryCommand>()
.send(StoryCommand::OpenSave);
world.step();
assert!(label_content(&world, "s_stage_slot_title").contains("Save"));
assert!(label_content(&world, "s_stage_slot0_lbl").contains("empty"));
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Slot(0));
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Second page.");
let saved = read_save(&slot_file(dir.path(), 0)).expect("slot written");
assert_eq!(saved.page, 1);
world.events_mut::<StoryCommand>().send(StoryCommand::Start);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "First page.");
world
.events_mut::<StoryCommand>()
.send(StoryCommand::OpenLoad);
world.step();
assert!(label_content(&world, "s_stage_slot0_lbl").contains("page 2"));
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Slot(0));
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Second page.");
}
#[test]
fn holding_ctrl_fast_forwards_and_lights_skip() {
let mut story = two_page_story();
story.text_speed = 0.0001;
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "");
world.add_component(FrameInput {
ctrl: true,
..Default::default()
});
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "First page.");
assert_eq!(label_color(&world, "s_stage_qskip_lbl"), QUICK_ACTIVE);
world.add_component(FrameInput::default());
world.step();
assert_eq!(label_color(&world, "s_stage_qskip_lbl"), QUICK_IDLE);
}
#[test]
fn slot_overlay_scrolls_the_window_over_all_slots() {
let dir = tempfile::tempdir().unwrap();
let mut story = two_page_story();
story.save_key = "scroll_test".to_string();
let mut world = multi_slot_world(story, 3);
world.start(SYSTEMS).unwrap();
for system in world.systems_mut() {
if let Some(s) = system.downcast_mut::<StorySystem>() {
s.save_dir = Some(dir.path().to_path_buf());
}
}
let save = StorySave {
slug: "a".to_string(),
page: 1,
vars: BTreeMap::new(),
};
write_save(&slot_file(dir.path(), 6), &save).unwrap();
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::OpenLoad);
world.step();
assert!(label_content(&world, "s_stage_slot0_lbl").contains("Slot 1"));
assert!(label_content(&world, "s_stage_slot_title").contains("1-3 / 10"));
world.add_component(FrameInput {
scroll_delta: SLOT_SCROLL_UNIT * 6.0,
..Default::default()
});
world.step();
assert!(label_content(&world, "s_stage_slot0_lbl").contains("Slot 7"));
assert!(label_content(&world, "s_stage_slot0_lbl").contains("page 2"));
assert!(label_content(&world, "s_stage_slot_title").contains("7-9 / 10"));
world.add_component(FrameInput::default());
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Slot(0));
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Second page.");
}
#[test]
fn pause_menu_save_raises_stage_and_opens_slots() {
let dir = tempfile::tempdir().unwrap();
let mut story = two_page_story();
story.save_key = "pause_save".to_string();
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
for system in world.systems_mut() {
if let Some(s) = system.downcast_mut::<StorySystem>() {
s.save_dir = Some(dir.path().to_path_buf());
}
}
world.step();
world.events_mut::<ScreenShown>().send(ScreenShown {
screen: intern("s_pause"),
});
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::OpenSave);
world.step();
assert!(label_content(&world, "s_stage_slot_title").contains("Save"));
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Slot(0));
world.step();
let saved = read_save(&slot_file(dir.path(), 0)).expect("slot written");
assert_eq!(saved.page, 0);
}
#[test]
fn pause_toggle_returns_to_the_stage_not_a_blank_view() {
let mut world = story_world_with_pause(two_page_story());
world.start(SYSTEMS).unwrap();
world.step();
assert!(sprite_visible(&world, "s_stage_bg"), "stage starts visible");
world
.events_mut::<StoryCommand>()
.send(StoryCommand::TogglePause);
world.step();
world.step();
assert!(
sprite_visible(&world, "s_pause_dim"),
"pause menu shown on Escape"
);
assert!(
!sprite_visible(&world, "s_stage_bg"),
"stage hidden behind the pause"
);
world
.events_mut::<StoryCommand>()
.send(StoryCommand::TogglePause);
world.step();
world.step();
assert!(
sprite_visible(&world, "s_stage_bg"),
"resume returns to the stage, not a blank screen"
);
assert!(!sprite_visible(&world, "s_pause_dim"));
}
#[test]
fn settings_back_returns_to_the_opener() {
let mut world = story_world_with_pause(two_page_story());
world.start(SYSTEMS).unwrap();
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::TogglePause);
world.step();
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::OpenSettings);
world.step();
world.step();
assert!(sprite_visible(&world, "s_settings_dim"), "settings shown");
world
.events_mut::<StoryCommand>()
.send(StoryCommand::CloseSettings);
world.step();
world.step();
assert!(
sprite_visible(&world, "s_pause_dim"),
"Back from settings returns to the pause menu"
);
world
.events_mut::<ScreenCommand>()
.send(ScreenCommand::Show(intern("s_title")));
world.step();
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::OpenSettings);
world.step();
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::CloseSettings);
world.step();
world.step();
assert!(
sprite_visible(&world, "s_title_bg"),
"Back from settings opened at the title returns to the title"
);
}
#[test]
fn title_load_works_after_returning_to_the_title() {
let dir = tempfile::tempdir().unwrap();
let mut story = two_page_story();
story.save_key = "replay".to_string();
let mut world = story_world_with_pause(story);
world.start(SYSTEMS).unwrap();
for system in world.systems_mut() {
if let Some(s) = system.downcast_mut::<StorySystem>() {
s.save_dir = Some(dir.path().to_path_buf());
}
}
world.step(); let save = StorySave {
slug: "a".to_string(),
page: 1,
vars: BTreeMap::new(),
};
write_save(&slot_file(dir.path(), 0), &save).unwrap();
world
.events_mut::<ScreenCommand>()
.send(ScreenCommand::Show(intern("s_title")));
world.step();
world.step();
assert!(sprite_visible(&world, "s_title_bg"), "title shown");
world
.events_mut::<StoryCommand>()
.send(StoryCommand::OpenLoad);
world.step();
world.step();
assert!(
label_content(&world, "s_stage_slot_title").contains("Load"),
"load overlay opens from the title after a playthrough"
);
}
#[test]
fn choice_survives_a_spurious_slot_click() {
let story = Story {
title: "T".to_string(),
text_speed: 0.0,
nodes: vec![
StoryNode {
slug: "a".to_string(),
pages: vec![page("Pick.")],
choices: vec![StoryChoice {
label: "Go".to_string(),
target: 1,
condition: None,
}],
..Default::default()
},
StoryNode {
slug: "b".to_string(),
pages: vec![page("Picked.")],
..Default::default()
},
],
..Default::default()
};
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
assert_eq!(label_content(&world, "s_stage_opt0_lbl"), "Go");
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Choose(0));
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Slot(1));
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Picked.");
}
#[test]
fn title_menu_lays_out_on_first_shown_not_at_init() {
let dir = tempfile::tempdir().unwrap();
let mut story = two_page_story();
story.save_key = "titletest".to_string();
let mut world = title_menu_world(story);
world.start(SYSTEMS).unwrap();
assert_eq!(label_y(&world, "s_title_start_lbl"), 100.0);
assert_eq!(label_y(&world, "s_title_quit_lbl"), 400.0);
for system in world.systems_mut() {
if let Some(s) = system.downcast_mut::<StorySystem>() {
s.save_dir = Some(dir.path().to_path_buf());
}
}
world.step();
let start = label_y(&world, "s_title_start_lbl");
let quit = label_y(&world, "s_title_quit_lbl");
assert_eq!(start, TITLE_MENU_CENTER_Y - TITLE_MENU_SPACING / 2.0);
assert_eq!(quit, TITLE_MENU_CENTER_Y + TITLE_MENU_SPACING / 2.0);
assert_eq!(quit - start, TITLE_MENU_SPACING);
assert_eq!(label_content(&world, "s_title_start_lbl"), "Start");
assert_eq!(label_content(&world, "s_title_quit_lbl"), "Quit");
assert_eq!(label_content(&world, "s_title_continue_lbl"), "");
assert_eq!(label_content(&world, "s_title_load_lbl"), "");
}
fn point_saves(world: &mut World, dir: &std::path::Path) {
for system in world.systems_mut() {
if let Some(s) = system.downcast_mut::<StorySystem>() {
s.save_dir = Some(dir.to_path_buf());
}
}
}
fn backdate_clock(world: &mut World, secs: f32) {
for system in world.systems_mut() {
if let Some(s) = system.downcast_mut::<StorySystem>() {
s.last_step = Some(Instant::now() - std::time::Duration::from_secs_f32(secs));
}
}
}
fn two_option_world(story: Story) -> World {
let mut world = World::new();
let mut story = story;
story.asset_id = intern("s");
let mut sc = scaffold();
sc.option_boxes = vec![intern("s_stage_opt0_box"), intern("s_stage_opt1_box")];
sc.options = vec![intern("s_stage_opt0_lbl"), intern("s_stage_opt1_lbl")];
story.scaffold = sc;
world.add_component(story);
for screen in ["s_stage", "s_ending"] {
world.add_component(Screen {
asset_id: intern(screen),
initial: screen == "s_stage",
fade_in_secs: 0.0,
..Default::default()
});
}
add_stage_furniture(&mut world);
world.add_component(Sprite {
screen: Some(intern("s_stage")),
..sprite_named("s_stage_opt1_box")
});
world.add_component(TextLabel {
screen: Some(intern("s_stage")),
..label_named("s_stage_opt1_lbl")
});
world
}
fn one_choice_story(label: &str, sounds: Vec<AudioClipHandle>) -> Story {
Story {
title: "T".to_string(),
text_speed: 0.0,
nodes: vec![
StoryNode {
slug: "a".to_string(),
pages: vec![page("Pick.")],
choices: vec![StoryChoice {
label: label.to_string(),
target: 1,
condition: None,
}],
choice_sounds: sounds,
..Default::default()
},
StoryNode {
slug: "b".to_string(),
pages: vec![page("Picked.")],
..Default::default()
},
],
..Default::default()
}
}
#[test]
fn typewriter_reveals_the_page_over_time() {
let mut story = two_page_story();
story.text_speed = 100.0;
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "");
backdate_clock(&mut world, 0.5);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "First page.");
}
#[test]
fn toggle_skip_snaps_the_current_page_to_full() {
let mut story = two_page_story();
story.text_speed = 0.0001;
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "");
world
.events_mut::<StoryCommand>()
.send(StoryCommand::ToggleSkip);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "First page.");
assert_eq!(label_color(&world, "s_stage_qskip_lbl"), QUICK_ACTIVE);
}
#[test]
fn quick_toggle_is_inert_during_a_choice_menu() {
let mut world = story_world(one_choice_story("Go", Vec::new()));
world.start(SYSTEMS).unwrap();
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
assert_eq!(label_content(&world, "s_stage_qskip_lbl"), "");
world
.events_mut::<StoryCommand>()
.send(StoryCommand::ToggleSkip);
world.step();
assert_eq!(label_content(&world, "s_stage_qskip_lbl"), "");
}
#[test]
fn skip_mode_turns_pages_at_its_cadence() {
let mut world = story_world(two_page_story());
world.start(SYSTEMS).unwrap();
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "First page.");
world
.events_mut::<StoryCommand>()
.send(StoryCommand::ToggleSkip);
backdate_clock(&mut world, 1.0);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Second page.");
}
#[test]
fn auto_mode_turns_a_read_page_after_the_delay() {
let mut world = story_world(two_page_story());
world.start(SYSTEMS).unwrap();
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::ToggleAuto);
backdate_clock(&mut world, 10.0);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Second page.");
}
#[test]
fn pageless_node_enters_its_choice_menu_directly() {
let story = Story {
title: "T".to_string(),
text_speed: 0.0,
nodes: vec![
StoryNode {
slug: "a".to_string(),
pages: Vec::new(),
choices: vec![StoryChoice {
label: "Go".to_string(),
target: 1,
condition: None,
}],
..Default::default()
},
StoryNode {
slug: "b".to_string(),
pages: vec![page("Picked.")],
..Default::default()
},
],
..Default::default()
};
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
world.step();
assert_eq!(label_content(&world, "s_stage_opt0_lbl"), "Go");
}
#[test]
fn pageless_choice_gate_redirects_on_entry() {
use crate::components::StoryGate;
let story = Story {
title: "T".to_string(),
text_speed: 0.0,
nodes: vec![
StoryNode {
slug: "a".to_string(),
pages: Vec::new(),
choices: vec![StoryChoice {
label: "Never".to_string(),
target: 0,
condition: None,
}],
choice_gates: vec![StoryGate {
name: "x".to_string(),
op: StoryCompareOp::Eq,
value: 0,
target: 1,
}],
..Default::default()
},
StoryNode {
slug: "b".to_string(),
pages: vec![page("Landed.")],
..Default::default()
},
],
..Default::default()
};
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Landed.");
}
#[test]
fn first_page_gate_redirects_on_node_entry() {
use crate::components::StoryGate;
let story = Story {
title: "T".to_string(),
text_speed: 0.0,
nodes: vec![
StoryNode {
slug: "a".to_string(),
pages: vec![StoryPage {
gates: vec![StoryGate {
name: "x".to_string(),
op: StoryCompareOp::Eq,
value: 0,
target: 1,
}],
..page("Skipped.")
}],
..Default::default()
},
StoryNode {
slug: "b".to_string(),
pages: vec![page("Landed.")],
..Default::default()
},
],
..Default::default()
};
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Landed.");
}
#[test]
fn all_gated_choices_fall_through() {
use crate::components::StoryCondition;
let story = Story {
title: "T".to_string(),
text_speed: 0.0,
nodes: vec![
StoryNode {
slug: "a".to_string(),
pages: Vec::new(),
choices: vec![StoryChoice {
label: "Secret".to_string(),
target: 2,
condition: Some(StoryCondition {
name: "secret".to_string(),
op: StoryCompareOp::Ne,
value: 0,
}),
}],
..Default::default()
},
StoryNode {
slug: "b".to_string(),
pages: vec![page("Fell through.")],
..Default::default()
},
],
..Default::default()
};
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Fell through.");
}
#[test]
fn gate_loop_is_stopped_by_the_hop_limit() {
use crate::components::StoryGate;
let dummy = || StoryChoice {
label: "x".to_string(),
target: 0,
condition: None,
};
let gate_to = |target: u32| StoryGate {
name: "x".to_string(),
op: StoryCompareOp::Eq,
value: 0,
target,
};
let story = Story {
title: "T".to_string(),
text_speed: 0.0,
nodes: vec![
StoryNode {
slug: "a".to_string(),
pages: Vec::new(),
choices: vec![dummy()],
choice_gates: vec![gate_to(1)],
..Default::default()
},
StoryNode {
slug: "b".to_string(),
pages: Vec::new(),
choices: vec![dummy()],
choice_gates: vec![gate_to(0)],
..Default::default()
},
],
..Default::default()
};
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "");
}
#[test]
fn skip_snaps_a_freshly_entered_page_to_full() {
let mut story = two_page_story();
story.text_speed = 0.0001;
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::ToggleSkip);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "First page.");
backdate_clock(&mut world, 1.0);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Second page.");
}
#[test]
fn choice_menu_fires_its_one_shot_sounds() {
let click = AudioClipHandle(0);
let mut world = story_world(one_choice_story("Go", vec![click]));
world.start(SYSTEMS).unwrap();
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
let mut cursor = crate::ecs::EventCursor::default();
let cues: Vec<PlayCue> = world
.events_mut::<PlayCue>()
.read(&mut cursor)
.copied()
.collect();
assert!(
cues.iter()
.any(|c| c.clip == click && c.kind == CueKind::Sound),
"choice sound cue fired: {cues:?}"
);
}
#[test]
fn unoccupied_option_slots_blank_and_go_transparent() {
let mut world = two_option_world(one_choice_story("Go", Vec::new()));
world.start(SYSTEMS).unwrap();
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
assert_eq!(label_content(&world, "s_stage_opt0_lbl"), "Go");
assert_eq!(label_content(&world, "s_stage_opt1_lbl"), "");
let box1 = world
.query::<Sprite>()
.find(|s| s.asset_id == intern("s_stage_opt1_box"))
.unwrap();
assert_eq!(box1.tint[3], 0.0, "empty slot box is transparent");
let box0 = world
.query::<Sprite>()
.find(|s| s.asset_id == intern("s_stage_opt0_box"))
.unwrap();
assert!(box0.tint[3] > 0.0, "occupied slot box is opaque");
}
#[test]
fn continue_resumes_from_a_written_auto_save() {
let dir = tempfile::tempdir().unwrap();
let mut story = two_page_story();
story.save_key = "cont".to_string();
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
point_saves(&mut world, dir.path());
world.step();
write_save(
&save_file(dir.path()),
&StorySave {
slug: "a".to_string(),
page: 1,
vars: BTreeMap::new(),
},
)
.unwrap();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Continue);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "Second page.");
}
#[test]
fn continue_from_an_unknown_slug_starts_fresh() {
let dir = tempfile::tempdir().unwrap();
let mut story = two_page_story();
story.save_key = "stale".to_string();
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
point_saves(&mut world, dir.path());
world.step();
write_save(
&save_file(dir.path()),
&StorySave {
slug: "ghost".to_string(),
page: 5,
vars: BTreeMap::new(),
},
)
.unwrap();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Continue);
world.step();
assert_eq!(label_content(&world, "s_stage_text"), "First page.");
}
#[test]
fn continue_into_a_pageless_node_starts_fresh() {
let dir = tempfile::tempdir().unwrap();
let story = Story {
title: "T".to_string(),
text_speed: 0.0,
save_key: "pageless".to_string(),
nodes: vec![
StoryNode {
slug: "a".to_string(),
pages: Vec::new(),
choices: vec![StoryChoice {
label: "Go".to_string(),
target: 1,
condition: None,
}],
..Default::default()
},
StoryNode {
slug: "b".to_string(),
pages: vec![page("Picked.")],
..Default::default()
},
],
..Default::default()
};
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
point_saves(&mut world, dir.path());
world.step();
write_save(
&save_file(dir.path()),
&StorySave {
slug: "a".to_string(),
page: 0,
vars: BTreeMap::new(),
},
)
.unwrap();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Continue);
world.step();
assert_eq!(label_content(&world, "s_stage_opt0_lbl"), "Go");
}
#[test]
fn reaching_the_ending_clears_the_auto_save() {
let dir = tempfile::tempdir().unwrap();
let mut story = two_page_story();
story.save_key = "endclear".to_string();
let mut world = story_world(story);
world.start(SYSTEMS).unwrap();
point_saves(&mut world, dir.path());
world.step();
assert!(
read_save(&save_file(dir.path())).is_some(),
"the first page auto-saved"
);
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
world
.events_mut::<StoryCommand>()
.send(StoryCommand::Advance);
world.step();
assert!(
read_save(&save_file(dir.path())).is_none(),
"the ending cleared the auto-save"
);
}