use alloc::sync::Arc;
use alloc::vec;
use brink_format::Value;
use crate::error::RuntimeError;
use crate::rng::StoryRng;
use crate::state::ContextAccess;
use crate::story::Flow;
fn draw<R: StoryRng>(context: &mut (impl ContextAccess + ?Sized)) -> i32 {
let seed = context.rng_seed().wrapping_add(context.previous_random());
let next = context.next_random::<R>(seed);
context.set_previous_random(next);
next
}
fn draw_unit_float<R: StoryRng>(context: &mut (impl ContextAccess + ?Sized)) -> f32 {
let d = draw::<R>(context);
#[expect(clippy::cast_sign_loss)]
let top = (d as u32) >> 7;
#[expect(clippy::cast_precision_loss)]
{
top as f32 / 16_777_216.0
}
}
pub(crate) fn rand_float<R: StoryRng>(
flow: &mut Flow,
context: &mut (impl ContextAccess + ?Sized),
) {
let f = draw_unit_float::<R>(context);
flow.value_stack.push(Value::Float(f));
}
pub(crate) fn rand_chance<R: StoryRng>(
flow: &mut Flow,
context: &mut (impl ContextAccess + ?Sized),
) -> Result<(), RuntimeError> {
let p_val = flow.pop_value()?;
#[expect(clippy::cast_precision_loss)]
let p = match &p_val {
Value::Float(f) => *f,
Value::Int(n) => *n as f32,
Value::Bool(b) => {
if *b { 1.0 } else { 0.0 }
}
other => {
return Err(RuntimeError::StdlibWrongType {
verb: "chance",
expected: "a number",
found: super::collection_ops::type_name(other),
});
}
};
let u = draw_unit_float::<R>(context);
flow.value_stack.push(Value::Bool(u < p));
Ok(())
}
pub(crate) fn rand_int<R: StoryRng>(
flow: &mut Flow,
context: &mut (impl ContextAccess + ?Sized),
) -> Result<(), RuntimeError> {
let v = flow.pop_value()?;
let (Some((start, end, inclusive)), Some(len)) = (v.as_range(), v.range_len()) else {
return Err(RuntimeError::StdlibWrongType {
verb: "int",
expected: "a range",
found: super::collection_ops::type_name(&v),
});
};
if len == 0 {
let range = if inclusive {
alloc::format!("{start}..={end}")
} else {
alloc::format!("{start}..{end}")
};
return Err(RuntimeError::EmptyRangeDraw { range });
}
let d = draw::<R>(context);
let offset = i64::from(d) % len;
#[expect(
clippy::cast_possible_truncation,
reason = "start + offset is an element of the range by construction, so it fits i32"
)]
let value = (i64::from(start) + offset) as i32;
flow.value_stack.push(Value::Int(value));
Ok(())
}
pub(crate) fn rand_pick<R: StoryRng>(
flow: &mut Flow,
context: &mut (impl ContextAccess + ?Sized),
) -> Result<(), RuntimeError> {
let coll = flow.pop_value()?;
let picked = match &coll {
Value::Array(items) => {
if items.is_empty() {
Value::none()
} else {
let d = draw::<R>(context);
#[expect(clippy::cast_sign_loss)]
let idx = (d as usize) % items.len();
Value::some(items[idx].clone())
}
}
Value::List(lv) => {
if lv.items.is_empty() {
Value::none()
} else {
let d = draw::<R>(context);
#[expect(clippy::cast_sign_loss)]
let idx = (d as usize) % lv.items.len();
Value::some(Value::List(Arc::new(brink_format::ListValue {
items: vec![lv.items[idx]],
origins: lv.origins.clone(),
})))
}
}
Value::Range { start, .. } => {
let len = coll.range_len().unwrap_or(0);
if len == 0 {
Value::none()
} else {
let d = draw::<R>(context);
let offset = i64::from(d) % len;
#[expect(
clippy::cast_possible_truncation,
reason = "start + offset is an element of the range, so it fits i32"
)]
Value::some(Value::Int((i64::from(*start) + offset) as i32))
}
}
other => {
return Err(RuntimeError::StdlibWrongType {
verb: "pick",
expected: "an array, flags subset, or range",
found: super::collection_ops::type_name(other),
});
}
};
flow.value_stack.push(picked);
Ok(())
}
pub(crate) fn rand_shuffle<R: StoryRng>(
flow: &mut Flow,
context: &mut (impl ContextAccess + ?Sized),
) -> Result<(), RuntimeError> {
let mut coll = flow.pop_value()?;
if !matches!(coll, Value::Array(_)) {
return Err(RuntimeError::StdlibWrongType {
verb: "shuffle",
expected: "an array",
found: super::collection_ops::type_name(&coll),
});
}
if let Some(items) = coll.array_make_mut() {
for i in (1..items.len()).rev() {
let d = draw::<R>(context);
#[expect(clippy::cast_sign_loss)]
let j = (d as usize) % (i + 1);
items.swap(i, j);
}
}
flow.value_stack.push(coll);
Ok(())
}
pub(crate) fn rand_roll<R: StoryRng>(
flow: &mut Flow,
context: &mut (impl ContextAccess + ?Sized),
) -> Result<(), RuntimeError> {
let table = flow.pop_value()?;
let Value::Weighted(w) = &table else {
return Err(RuntimeError::StdlibWrongType {
verb: "roll",
expected: "a weighted table",
found: super::collection_ops::type_name(&table),
});
};
let total = w.total_weight();
let d = draw::<R>(context);
let mut offset = i64::from(d) % total;
for (weight, value) in &w.entries {
offset -= i64::from(*weight);
if offset < 0 {
flow.value_stack.push(value.clone());
return Ok(());
}
}
Err(RuntimeError::WeightedMalformedTable {
detail: "a draw walk that exhausted the table (corrupt total weight)",
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::output::OutputBuffer;
use crate::rng::{DotNetRng, FastRng};
use crate::story::PendingTerminal;
use crate::world::World;
use alloc::vec::Vec;
fn test_flow() -> Flow {
Flow {
threads: Vec::new(),
value_stack: Vec::new(),
output: OutputBuffer::new(),
pending_choices: Vec::new(),
current_tags: Vec::new(),
in_tag: false,
skipping_choice: false,
did_safe_exit: false,
did_unsafe_yield: false,
ran_out_of_content_cause: crate::RanOutOfContentCause::default(),
line_delivered_this_turn: false,
exec_mode: crate::story::ExecMode::default(),
pure_callback: crate::story::PureCallbackState::default(),
next_block_id: 0,
pending_terminal: PendingTerminal::default(),
warnings: Vec::new(),
}
}
fn test_context() -> World {
World::from_globals(Vec::new(), crate::world::ResolvedPolicy::all_world())
}
#[test]
fn unit_float_is_in_half_open_interval_and_advances_the_cell() {
let mut ctx = test_context();
ctx.set_rng_seed(42);
let mut prev_state = ctx.previous_random();
for _ in 0..1000 {
let f = draw_unit_float::<DotNetRng>(&mut ctx);
assert!((0.0..1.0).contains(&f), "draw out of [0,1): {f}");
assert_ne!(
ctx.previous_random(),
prev_state,
"a draw must advance the cell"
);
prev_state = ctx.previous_random();
}
}
#[test]
fn draws_are_a_pure_function_of_state() {
let mut a = test_context();
let mut b = test_context();
a.set_rng_seed(7);
b.set_rng_seed(7);
for _ in 0..100 {
assert_eq!(draw::<DotNetRng>(&mut a), draw::<DotNetRng>(&mut b));
assert_eq!(a.previous_random(), b.previous_random());
}
a.set_rng_seed(7);
a.set_previous_random(0);
b.set_rng_seed(7);
b.set_previous_random(0);
for _ in 0..100 {
assert_eq!(draw::<FastRng>(&mut a), draw::<FastRng>(&mut b));
}
}
#[test]
fn chance_extremes_and_nan() {
let mut ctx = test_context();
ctx.set_rng_seed(1);
for _ in 0..50 {
let mut flow = test_flow();
flow.value_stack.push(Value::Float(1.0));
rand_chance::<DotNetRng>(&mut flow, &mut ctx).unwrap();
assert_eq!(flow.pop_value().unwrap(), Value::Bool(true));
}
for _ in 0..50 {
let mut flow = test_flow();
flow.value_stack.push(Value::Float(0.0));
rand_chance::<DotNetRng>(&mut flow, &mut ctx).unwrap();
assert_eq!(flow.pop_value().unwrap(), Value::Bool(false));
}
let before = ctx.previous_random();
let mut flow = test_flow();
flow.value_stack.push(Value::Float(f32::NAN));
rand_chance::<DotNetRng>(&mut flow, &mut ctx).unwrap();
assert_eq!(flow.pop_value().unwrap(), Value::Bool(false));
assert_ne!(ctx.previous_random(), before, "NaN chance must still draw");
let mut flow = test_flow();
flow.value_stack.push(Value::Float(2.5));
rand_chance::<DotNetRng>(&mut flow, &mut ctx).unwrap();
assert_eq!(flow.pop_value().unwrap(), Value::Bool(true));
let mut flow = test_flow();
flow.value_stack.push(Value::Float(-3.0));
rand_chance::<DotNetRng>(&mut flow, &mut ctx).unwrap();
assert_eq!(flow.pop_value().unwrap(), Value::Bool(false));
}
#[test]
fn chance_wrong_type_faults() {
let mut ctx = test_context();
let mut flow = test_flow();
flow.value_stack.push(Value::from("nope"));
let err = rand_chance::<DotNetRng>(&mut flow, &mut ctx).unwrap_err();
assert!(matches!(
err,
RuntimeError::StdlibWrongType { verb: "chance", .. }
));
}
#[test]
fn pick_empty_is_none_and_consumes_no_draw() {
let mut ctx = test_context();
ctx.set_rng_seed(9);
let before = ctx.previous_random();
let mut flow = test_flow();
flow.value_stack.push(Value::array(vec![]));
rand_pick::<DotNetRng>(&mut flow, &mut ctx).unwrap();
assert_eq!(flow.pop_value().unwrap(), Value::none());
assert_eq!(
ctx.previous_random(),
before,
"empty pick must not consume a draw"
);
}
#[test]
fn pick_draws_a_member() {
let mut ctx = test_context();
ctx.set_rng_seed(3);
let items = vec![Value::Int(10), Value::Int(20), Value::Int(30)];
for _ in 0..100 {
let mut flow = test_flow();
flow.value_stack.push(Value::array(items.clone()));
rand_pick::<DotNetRng>(&mut flow, &mut ctx).unwrap();
let picked = flow.pop_value().unwrap();
let Value::OptionVal(Some(inner)) = picked else {
unreachable!("pick over non-empty must be some(_)");
};
assert!(items.contains(&inner), "picked a non-member: {inner:?}");
}
}
#[test]
fn pick_wrong_type_faults() {
let mut ctx = test_context();
let mut flow = test_flow();
flow.value_stack.push(Value::Int(5));
let err = rand_pick::<DotNetRng>(&mut flow, &mut ctx).unwrap_err();
assert!(matches!(
err,
RuntimeError::StdlibWrongType { verb: "pick", .. }
));
}
#[test]
fn shuffle_is_a_permutation_and_seed_deterministic() {
let original: Vec<Value> = (0..10).map(Value::Int).collect();
let run = |seed: i32| -> Vec<Value> {
let mut ctx = test_context();
ctx.set_rng_seed(seed);
let mut flow = test_flow();
flow.value_stack.push(Value::array(original.clone()));
rand_shuffle::<DotNetRng>(&mut flow, &mut ctx).unwrap();
let Value::Array(items) = flow.pop_value().unwrap() else {
unreachable!("shuffle must return an array");
};
items.as_ref().clone()
};
let a = run(11);
let b = run(11);
let c = run(12);
assert_eq!(a, b, "same seed must shuffle identically");
let mut sorted = a.clone();
sorted.sort_by_key(|v| if let Value::Int(n) = v { *n } else { -1 });
assert_eq!(sorted, original);
assert_ne!(a, c, "distinct seeds produced identical shuffles");
}
#[test]
fn shuffle_len_below_two_consumes_no_draw() {
for arr in [vec![], vec![Value::Int(1)]] {
let mut ctx = test_context();
ctx.set_rng_seed(5);
let before = ctx.previous_random();
let mut flow = test_flow();
flow.value_stack.push(Value::array(arr));
rand_shuffle::<DotNetRng>(&mut flow, &mut ctx).unwrap();
assert_eq!(ctx.previous_random(), before);
}
}
#[test]
fn rand_int_draws_within_bounds_both_forms() {
let mut ctx = test_context();
ctx.set_rng_seed(21);
for _ in 0..200 {
let mut flow = test_flow();
flow.value_stack.push(Value::range(1, 6, true));
rand_int::<DotNetRng>(&mut flow, &mut ctx).unwrap();
let Value::Int(n) = flow.pop_value().unwrap() else {
unreachable!("rand_int returns Int");
};
assert!((1..=6).contains(&n), "inclusive draw out of range: {n}");
}
for _ in 0..200 {
let mut flow = test_flow();
flow.value_stack.push(Value::range(0, 3, false));
rand_int::<DotNetRng>(&mut flow, &mut ctx).unwrap();
let Value::Int(n) = flow.pop_value().unwrap() else {
unreachable!("rand_int returns Int");
};
assert!((0..3).contains(&n), "exclusive draw out of range: {n}");
}
let before = ctx.previous_random();
let mut flow = test_flow();
flow.value_stack.push(Value::range(5, 5, true));
rand_int::<DotNetRng>(&mut flow, &mut ctx).unwrap();
assert_eq!(flow.pop_value().unwrap(), Value::Int(5));
assert_ne!(ctx.previous_random(), before, "5..=5 still consumes a draw");
}
#[test]
fn rand_int_consumes_exactly_one_draw() {
let mut a = test_context();
let mut b = test_context();
a.set_rng_seed(9);
b.set_rng_seed(9);
let mut flow = test_flow();
flow.value_stack.push(Value::range(1, 100, true));
rand_int::<DotNetRng>(&mut flow, &mut a).unwrap();
draw::<DotNetRng>(&mut b);
assert_eq!(a.previous_random(), b.previous_random());
}
#[test]
fn rand_int_empty_range_faults_without_drawing() {
for r in [
Value::range(0, 0, false),
Value::range(5, 5, false),
Value::range(7, 2, true),
] {
let mut ctx = test_context();
ctx.set_rng_seed(3);
let before = ctx.previous_random();
let mut flow = test_flow();
flow.value_stack.push(r);
let err = rand_int::<DotNetRng>(&mut flow, &mut ctx).unwrap_err();
assert!(matches!(err, RuntimeError::EmptyRangeDraw { .. }));
assert_eq!(ctx.previous_random(), before);
}
let mut ctx = test_context();
let mut flow = test_flow();
flow.value_stack.push(Value::range(5, 2, true));
let RuntimeError::EmptyRangeDraw { range } =
rand_int::<DotNetRng>(&mut flow, &mut ctx).unwrap_err()
else {
unreachable!("empty range must fault EmptyRangeDraw");
};
assert_eq!(range, "5..=2");
}
#[test]
fn rand_int_wrong_type_faults() {
let mut ctx = test_context();
let mut flow = test_flow();
flow.value_stack.push(Value::Int(6));
let err = rand_int::<DotNetRng>(&mut flow, &mut ctx).unwrap_err();
assert!(matches!(
err,
RuntimeError::StdlibWrongType { verb: "int", .. }
));
}
#[test]
fn pick_range_draws_an_element_and_empty_is_none() {
let mut ctx = test_context();
ctx.set_rng_seed(13);
for _ in 0..100 {
let mut flow = test_flow();
flow.value_stack.push(Value::range(10, 13, false));
rand_pick::<DotNetRng>(&mut flow, &mut ctx).unwrap();
let Value::OptionVal(Some(inner)) = flow.pop_value().unwrap() else {
unreachable!("pick over non-empty range must be some(_)");
};
let Value::Int(n) = *inner else {
unreachable!("pick over a range yields ints");
};
assert!((10..13).contains(&n), "picked non-member: {n}");
}
let before = ctx.previous_random();
let mut flow = test_flow();
flow.value_stack.push(Value::range(4, 4, false));
rand_pick::<DotNetRng>(&mut flow, &mut ctx).unwrap();
assert_eq!(flow.pop_value().unwrap(), Value::none());
assert_eq!(ctx.previous_random(), before, "empty pick must not draw");
}
#[test]
fn shuffle_wrong_type_faults() {
let mut ctx = test_context();
let mut flow = test_flow();
flow.value_stack.push(Value::from("abc"));
let err = rand_shuffle::<DotNetRng>(&mut flow, &mut ctx).unwrap_err();
assert!(matches!(
err,
RuntimeError::StdlibWrongType {
verb: "shuffle",
..
}
));
}
fn roll_table() -> Value {
Value::weighted(vec![
(3, Value::String("sword".into())),
(1, Value::String("shield".into())),
])
}
#[test]
fn roll_is_total_deterministic_and_advances_the_cell() {
let mut a = test_context();
let mut b = test_context();
a.set_rng_seed(7);
b.set_rng_seed(7);
for _ in 0..200 {
let before = a.previous_random();
let mut fa = test_flow();
let mut fb = test_flow();
fa.value_stack.push(roll_table());
fb.value_stack.push(roll_table());
rand_roll::<DotNetRng>(&mut fa, &mut a).unwrap();
rand_roll::<DotNetRng>(&mut fb, &mut b).unwrap();
let va = fa.pop_value().unwrap();
assert_eq!(va, fb.pop_value().unwrap(), "seeded replay identical");
assert!(
matches!(&va, Value::String(s) if s.as_ref() == "sword" || s.as_ref() == "shield"),
"roll lands on an entry: {va:?}"
);
assert_ne!(a.previous_random(), before, "a roll must draw");
}
}
#[test]
fn roll_respects_weights_over_many_draws() {
let mut ctx = test_context();
ctx.set_rng_seed(11);
let mut swords = 0u32;
for _ in 0..4000 {
let mut flow = test_flow();
flow.value_stack.push(roll_table());
rand_roll::<DotNetRng>(&mut flow, &mut ctx).unwrap();
if flow.pop_value().unwrap() == Value::String("sword".into()) {
swords += 1;
}
}
assert!(
(2700..=3300).contains(&swords),
"expected ~3000/4000 swords, got {swords}"
);
}
#[test]
fn roll_single_entry_table_is_the_identity_draw() {
let mut ctx = test_context();
ctx.set_rng_seed(3);
let mut flow = test_flow();
flow.value_stack
.push(Value::weighted(vec![(i32::MAX, Value::Int(9))]));
rand_roll::<DotNetRng>(&mut flow, &mut ctx).unwrap();
assert_eq!(flow.pop_value().unwrap(), Value::Int(9));
}
#[test]
fn roll_wrong_type_faults() {
let mut ctx = test_context();
let mut flow = test_flow();
flow.value_stack.push(Value::Int(3));
let err = rand_roll::<DotNetRng>(&mut flow, &mut ctx).unwrap_err();
assert_eq!(
err,
RuntimeError::StdlibWrongType {
verb: "roll",
expected: "a weighted table",
found: "int",
}
);
}
}