From d34d56954ff723784bd2e240c15c778915383880 Mon Sep 17 00:00:00 2001 From: Sam Balana Date: Sat, 18 Jul 2026 18:36:12 -0700 Subject: [PATCH 1/2] feat(parse): add reroll modifiers r/ro with chained compare points Reroll (`r`) redraws a die whose face matches a compare point until it clears; `ro` redraws once. A bare number means equality (`r1` = `r=1`), and `<`/`>`/`=` work too. Chain compare points to reroll several faces (`6d6r2r4r6`). A recurring reroll that would match every face is rejected; `ro` of the same shape is allowed since it always terminates. Reroll is decided up front, before explode/keep/drop/multiply, through a single `draw_with_reroll` helper shared by the animated path, the headless `evaluate`, and the stats sampler, so the rule can't drift. Discarded faces ride along in `OutcomeDie.rerolled` for the `-v`/`--json` breakdown; the arena only ever shows the survivor. Docs (README, help overlay, AGENT.md, CONTRIBUTING.md) updated for the new reroll -> explode -> keep/drop -> multiply order. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENT.md | 20 +-- CONTRIBUTING.md | 8 +- README.md | 21 +++- crates/tinhorn-core/src/app.rs | 201 +++++++++++++++++++++++++++---- crates/tinhorn-core/src/parse.rs | 132 ++++++++++++++++++++ crates/tinhorn/src/cli.rs | 50 +++++++- crates/tinhorn/src/ui.rs | 12 +- 7 files changed, 398 insertions(+), 46 deletions(-) diff --git a/AGENT.md b/AGENT.md index 6f2704b..197cd61 100644 --- a/AGENT.md +++ b/AGENT.md @@ -79,8 +79,9 @@ and `drain_sounds` plays whatever the physics queued. - **`parse`** (core) — hand-written parser: notation → `Roll` (a `Vec` + flat `i32` modifier + optional `Stake` for staked rolls). Each `DiceTerm` is - count, sides, and modifiers (`TermMod`: keep/drop, explode with a `Compare`, - multiply). Pure and unit-tested. Sizes are capped (≤ 60 dice, ≤ 1000 sides) so + count, sides, and modifiers (`TermMod`: reroll (`r`/`ro`, each a `Reroll` — + a `Compare` plus a once flag), keep/drop, explode with a `Compare`, multiply). + Pure and unit-tested. Sizes are capped (≤ 60 dice, ≤ 1000 sides) so a huge expression can't wedge the renderer; the `vs` target must come last and is range-checked into `i32`. A `Stake` bundles that target with a `Goal` (`Over` for `>` and its word alias `vs`, `Under` for the roll-under `<`) so a @@ -116,10 +117,15 @@ and `drain_sounds` plays whatever the physics queued. so it always converges). - `evaluate(expr, &Roll, &mut StdRng) -> Outcome` resolves a roll *instantly* into a full breakdown (`Outcome`/`OutcomeTerm`/`OutcomeDie`, all `serde`). It - **mirrors the animation's semantics exactly** — explode → keep/drop on the - base pool → per-term multiply → flat modifier. This is the shared contract: - the one-shot CLI and the TUI must agree, so changes to roll rules belong here - and in the animated path together. + **mirrors the animation's semantics exactly** — reroll → explode → keep/drop + on the base pool → per-term multiply → flat modifier. This is the shared + contract: the one-shot CLI and the TUI must agree, so changes to roll rules + belong here and in the animated path together. + - Reroll (`r`/`ro`) is decided *up front* like the value it replaces: + `draw_with_reroll()` is the single source both paths draw every face + through, so a `r1` die simply never lands on a 1 — no arena timeline, unlike + exploding. Discarded faces ride along in `OutcomeDie.rerolled` for the `-v` + breakdown; the arena only ever shows the survivor. - Single-source rule helpers back both paths: `check()` (the `vs` verdict for either `Goal` — meet-or-beat or roll-under — returning a direction-aware margin, also used by the stats pane's success odds), `Stake::label()` (the @@ -313,7 +319,7 @@ and `drain_sounds` plays whatever the physics queued. the codebase. - **Roll semantics live in two places that must stay in lockstep**: the animated path in `app` and `evaluate`. A test would fail if they diverge, but keep them - together when editing rules (explode/keep-drop/multiply order). Cross-cutting + together when editing rules (reroll/explode/keep-drop/multiply order). Cross-cutting rules (verdict, crit/fumble) live once in the shared helpers listed above. - **The RNG stays untouched**: throw power, verdicts, particles, and sound are all downstream of the same seedable RNG; a test asserts the same seed rolls diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8736d37..a80bdbf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,8 +62,8 @@ a `ScheduleRunnerPlugin` at ~60 fps — the core `App` is the single source of truth, and the Bevy entities are a pure view of it. - **`parse`** (core) — a hand-written parser that turns notation into a `Roll`: a - list of **dice terms** (each a count, a side count, and its modifiers — keep/drop, - explode, multiply) plus an integer flat modifier and the optional `Stake` (a + list of **dice terms** (each a count, a side count, and its modifiers — reroll, + keep/drop, explode, multiply) plus an integer flat modifier and the optional `Stake` (a target with a `Goal`: meet-or-beat `> N` / `vs N`, or roll-under `< N`). Pure and unit-tested. - **`app`** (core) — the state, the roll evaluator, and the glue that drives the @@ -189,8 +189,8 @@ Tests guard most of these, but know them before you lean on a wall: goes through `scene::convert` — a stray direct assignment is a compile error, not a silent unit bug. - **Roll semantics live in two places that must agree**: the animated path in - `app` and the instant `evaluate()`. Editing roll rules (explode → keep/drop - → multiply order) means editing both together. Cross-cutting rules + `app` and the instant `evaluate()`. Editing roll rules (reroll → explode → + keep/drop → multiply order) means editing both together. Cross-cutting rules (verdict, crit/fumble) live exactly once, in the shared helpers — never restate those comparisons inline. - **No plain-letter hotkeys.** Bare letters must stay typeable — `kh`/`dh` diff --git a/README.md b/README.md index 67f324b..8df8dda 100644 --- a/README.md +++ b/README.md @@ -110,9 +110,9 @@ the call with `--graphics kitty` or `--graphics blocks`. A roll is a sequence of **dice terms** and optional **flat modifiers**, separated by `+`, `,`, whitespace, or simply written next to each other. A term -can carry **modifiers** (keep/drop, explode, multiply) written right after its -`dN`; these apply in pool order — **explode → keep/drop → multiply** — and -stack. Add **stakes** to check the total against a target. +can carry **modifiers** (reroll, keep/drop, explode, multiply) written right +after its `dN`; these apply in pool order — **reroll → explode → keep/drop → +multiply** — and stack. Add **stakes** to check the total against a target. ### The basics @@ -140,6 +140,21 @@ wedge the renderer. size. Dropped dice are still thrown and bounce around — you watch advantage discard the lower d20 — but they're rendered dimmed and left out of the total. +### Reroll + +| Input | Meaning | +| ---------- | ------------------------------------------------------- | +| `4d6r1` | reroll any 1, repeating until it clears | +| `d20ro1` | `ro` rerolls **once** — one redraw, then live with it | +| `d20r<3` | a compare point works too (`<`, `>`, `=`); bare `N` = `=N` | +| `6d6r2r4r6`| chain compare points to reroll several faces | + +A reroll throws the old face out before anything else touches the pool, so a +die you'd have dropped or exploded is settled first. The die lands on its +kept face in the arena; a `-v` breakdown shows what was tossed (`1r4` = rolled +a 1, rerolled, kept the 4). A plain `r` whose compare would match *every* face +is rejected (it could never clear); use `ro` if you really mean one redraw. + ### Stakes Call a target and the arena hands down a verdict — margin and all. At most one diff --git a/crates/tinhorn-core/src/app.rs b/crates/tinhorn-core/src/app.rs index 597914a..25d4532 100644 --- a/crates/tinhorn-core/src/app.rs +++ b/crates/tinhorn-core/src/app.rs @@ -14,6 +14,7 @@ use rapier3d::prelude::RigidBodyHandle; const GRAVITY: f32 = 60.0; // 2D particle-burst gravity (cells/s²), not the dice const MAX_AIRBORNE: f32 = 8.0; // hard cap: a die tumbling this long is frozen in place const MAX_EXPLOSIONS: usize = 40; // cap on dice an exploding term can spawn, so the pool can't run away +const MAX_REROLLS: usize = 100; // per-die cap so a recurring reroll always terminates (well past any real use) // The Throw: shake-the-cup tuning. Power rises from 0 the moment the cup is // picked up and oscillates forever, so the release timing is the whole game. @@ -303,6 +304,10 @@ pub struct App { /// term. Explosions happen over the course of the animation, so the cap has /// to be enforced across frames rather than in one up-front loop. explosions: Vec, + /// Each term's reroll rules, indexed by term, so a die spawned mid-animation + /// by an explosion can inherit the same rerolls its base pool used. Empty + /// vecs for terms without a reroll modifier. + term_rerolls: Vec>, rng: StdRng, /// The 3D rigid-body world. Physics decides only where dice land, never the /// values. Stepped on a fixed timestep so insta and animated rolls settle @@ -363,6 +368,7 @@ impl App { muted: false, stats_cache: None, explosions: Vec::new(), + term_rerolls: Vec::new(), rng, physics: Physics::new(), phys_accum: 0.0, @@ -476,6 +482,7 @@ impl App { self.modifier = modifier; self.stake = stake; self.explosions = vec![0; terms.len()]; + self.term_rerolls = terms.iter().map(rerolls_of).collect(); let mut dice: Vec = Vec::new(); for (ti, term) in terms.iter().enumerate() { self.roll_term(ti, term, &mut dice); @@ -535,11 +542,13 @@ impl App { let start = out.len(); let base_color = start; let explode = explode_condition(term); + let rerolls = rerolls_of(term); - // Base pool, each tagged with the term's explode condition. + // Base pool, each tagged with the term's explode condition. Reroll is + // applied inside `new_die` as the value is drawn, before anything else. for _ in 0..term.count { let color = base_color + (out.len() - start); - out.push(self.new_die(term.sides, term_idx, color, explode)); + out.push(self.new_die(term.sides, term_idx, color, explode, &rerolls)); } // Keep/drop flags discarded dice out of the total. It runs on the base @@ -558,14 +567,18 @@ impl App { /// Build one freshly-rolled die (value decided up front; the animation just /// reveals it). `kept`/`mult` default to "counts at face value"; `explode` /// is the term's condition so the die can spawn a sibling when it settles. + /// `rerolls` are applied as the landed value is drawn — the discarded faces + /// are thrown away unseen (the arena only ever shows the survivor). fn new_die( &mut self, sides: u32, term_idx: usize, color_idx: usize, explode: Option, + rerolls: &[parse::Reroll], ) -> Die { - let final_value = self.rng.gen_range(1..=sides); + let mut discarded = Vec::new(); + let final_value = draw_with_reroll(sides, rerolls, &mut self.rng, &mut discarded); Die { sides, final_value, @@ -1012,9 +1025,12 @@ impl App { } // Drop the explosion dice from the top, exactly like a fresh throw. + // They inherit their term's reroll rules so a spawned die obeys the same + // `r`/`ro` as its pool. for (sides, term_idx, mult, cmp) in to_explode { let color = self.dice.len(); - let mut die = self.new_die(sides, term_idx, color, Some(cmp)); + let rerolls = self.term_rerolls[term_idx].clone(); + let mut die = self.new_die(sides, term_idx, color, Some(cmp), &rerolls); die.mult = mult; let x = self.rng.gen_range(-physics::HX * 0.6..=physics::HX * 0.6); let z = self.rng.gen_range(-physics::HZ * 0.5..=physics::HZ * 0.5); @@ -1359,18 +1375,24 @@ fn sample_total(roll: &Roll, rng: &mut StdRng) -> i32 { for term in &roll.terms { let explode = explode_condition(term); let mult = term_multiplier(term); + let rerolls = rerolls_of(term); + // The sampler only needs totals, so discarded faces are drawn into one + // reused scratch buffer and thrown away. + let mut scratch = Vec::new(); + let mut draw = |rng: &mut StdRng| { + scratch.clear(); + draw_with_reroll(term.sides, &rerolls, rng, &mut scratch) + }; // Base pool: (value, kept). Explosions append more dice, always kept. - let mut pool: Vec<(u32, bool)> = (0..term.count) - .map(|_| (rng.gen_range(1..=term.sides), true)) - .collect(); + let mut pool: Vec<(u32, bool)> = (0..term.count).map(|_| (draw(rng), true)).collect(); if let Some(cmp) = explode { let mut spawned = 0usize; let mut i = 0; while i < pool.len() { if cmp.matches(pool[i].0) && spawned < MAX_EXPLOSIONS { - pool.push((rng.gen_range(1..=term.sides), true)); + pool.push((draw(rng), true)); spawned += 1; } i += 1; @@ -1402,7 +1424,7 @@ fn sample_total(roll: &Roll, rng: &mut StdRng) -> i32 { } /// One rolled die in a headless evaluation. -#[derive(Debug, Clone, Copy, serde::Serialize)] +#[derive(Debug, Clone, serde::Serialize)] pub struct OutcomeDie { pub value: u32, /// Whether this die counts toward the total (false ⇒ dropped by keep/drop). @@ -1414,6 +1436,11 @@ pub struct OutcomeDie { pub crit: bool, /// A kept die on a 1 ([`fumble_face`]). pub fumble: bool, + /// Faces rolled and thrown out by a reroll (`r`/`ro`) before this die's + /// surviving `value`, in the order rolled. Empty — and omitted from JSON — + /// when the die was never rerolled. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub rerolled: Vec, } /// The result of evaluating one dice term headlessly: its dice and subtotal. @@ -1470,31 +1497,33 @@ pub fn evaluate(expression: &str, roll: &Roll, rng: &mut StdRng) -> Outcome { let explode = explode_condition(term); let mult = term_multiplier(term); let base = term.count as usize; - - // Base pool, then explosions appended (always kept). Track which dice - // were spawned by explosions so the output can flag them. - let mut dice: Vec = (0..base) - .map(|_| OutcomeDie { - value: rng.gen_range(1..=term.sides), + let rerolls = rerolls_of(term); + + // One base-pool die: draw its value (rerolling first), flagging none of + // the roll-outcome bits yet — keep/drop and crit are judged below. + let roll_die = |rng: &mut StdRng, exploded: bool| { + let mut discarded = Vec::new(); + let value = draw_with_reroll(term.sides, &rerolls, rng, &mut discarded); + OutcomeDie { + value, kept: true, - exploded: false, + exploded, crit: false, fumble: false, - }) - .collect(); + rerolled: discarded, + } + }; + + // Base pool, then explosions appended (always kept). Track which dice + // were spawned by explosions so the output can flag them. + let mut dice: Vec = (0..base).map(|_| roll_die(rng, false)).collect(); if let Some(cmp) = explode { let mut spawned = 0usize; let mut i = 0; while i < dice.len() { if cmp.matches(dice[i].value) && spawned < MAX_EXPLOSIONS { - dice.push(OutcomeDie { - value: rng.gen_range(1..=term.sides), - kept: true, - exploded: true, - crit: false, - fumble: false, - }); + dice.push(roll_die(rng, true)); spawned += 1; } i += 1; @@ -1583,6 +1612,23 @@ fn term_notation(term: &DiceTerm) -> String { TermMod::DropLow(n) => { let _ = write!(s, "dl{n}"); } + TermMod::Reroll(r) => { + s.push('r'); + if r.once { + s.push('o'); + } + match r.cmp { + parse::Compare::Eq(n) => { + let _ = write!(s, "{n}"); + } + parse::Compare::Gt(n) => { + let _ = write!(s, ">{n}"); + } + parse::Compare::Lt(n) => { + let _ = write!(s, "<{n}"); + } + } + } TermMod::Explode(None) => s.push('!'), TermMod::Explode(Some(parse::Compare::Eq(n))) => { let _ = write!(s, "!={n}"); @@ -1635,6 +1681,51 @@ fn term_multiplier(term: &DiceTerm) -> i32 { .product() } +/// The reroll rules on a term, in written order (`r2r4r6` → three rules). Empty +/// when the term has no reroll modifier — the common case, so callers can skip +/// the reroll machinery entirely. +fn rerolls_of(term: &DiceTerm) -> Vec { + term.mods + .iter() + .filter_map(|m| match m { + TermMod::Reroll(r) => Some(*r), + _ => None, + }) + .collect() +} + +/// Draw one face of a `sides`-sided die, applying the term's reroll rules: a +/// face matching any rule is thrown out and redrawn — repeatedly for a plain +/// `r` (until it clears every rule, capped by [`MAX_REROLLS`] so it always +/// terminates), or exactly once for `ro`. Discarded faces are pushed onto +/// `discarded` in the order rolled, so the breakdown can show what was thrown +/// away. Shared by the animated path ([`App::new_die`]) and the headless +/// [`evaluate`]/[`sample_total`] so the reroll rule can't drift between them. +pub fn draw_with_reroll( + sides: u32, + rerolls: &[parse::Reroll], + rng: &mut StdRng, + discarded: &mut Vec, +) -> u32 { + let mut v = rng.gen_range(1..=sides); + if rerolls.is_empty() { + return v; + } + // A single `ro` allows one redraw; any recurring `r` reopens the loop. + let cap = if rerolls.iter().any(|r| !r.once) { + MAX_REROLLS + } else { + 1 + }; + let mut n = 0; + while n < cap && rerolls.iter().any(|r| r.cmp.matches(v)) { + discarded.push(v); + v = rng.gen_range(1..=sides); + n += 1; + } + v +} + /// Flag dice out of a term's pool per its keep/drop modifiers. Operates on the /// dice's `final_value` (the result is decided up front), so the displayed /// running total already reflects the discard the whole way down. Multiple @@ -1711,6 +1802,7 @@ mod tests { muted: false, stats_cache: None, explosions: Vec::new(), + term_rerolls: Vec::new(), history: Vec::new(), pane: Pane::None, mode: RollMode::Shake, @@ -1761,6 +1853,65 @@ mod tests { assert_eq!(a, b, "insta must roll exactly the animation's dice"); } + #[test] + fn reroll_evaluate_clears_the_matched_faces() { + // A recurring `r1` on a big pool: no surviving die — base *or* + // exploded — is ever left showing a 1, and every discarded face was + // in fact a 1 (the compare it was rerolled for). + for seed in 0..40 { + let roll = parse::parse("12d6r1").unwrap(); + let mut rng = StdRng::seed_from_u64(seed); + let o = evaluate("12d6r1", &roll, &mut rng); + for d in &o.terms[0].dice { + assert_ne!(d.value, 1, "seed {seed}: an r1 die kept a 1"); + assert!( + d.rerolled.iter().all(|&v| v == 1), + "seed {seed}: only 1s should have been rerolled, got {:?}", + d.rerolled + ); + } + } + + // Chained compare points reroll every listed face. + let roll = parse::parse("20d6r2r4r6").unwrap(); + let mut rng = StdRng::seed_from_u64(7); + let o = evaluate("20d6r2r4r6", &roll, &mut rng); + for d in &o.terms[0].dice { + assert!( + !matches!(d.value, 2 | 4 | 6), + "a chained reroll kept a matched face: {}", + d.value + ); + } + } + + #[test] + fn reroll_once_redraws_at_most_once() { + // `ro` gives each die a single redraw, so it can still land on a + // matched face — but never discards more than one. + let roll = parse::parse("20d6ro1").unwrap(); + let mut rng = StdRng::seed_from_u64(3); + let o = evaluate("20d6ro1", &roll, &mut rng); + for d in &o.terms[0].dice { + assert!(d.rerolled.len() <= 1, "ro rerolled twice: {:?}", d.rerolled); + } + } + + #[test] + fn animated_reroll_follows_the_same_rule_as_evaluate() { + // The bouncing path decides values up front through the same + // `draw_with_reroll`, so an insta roll of `r1` also never keeps a 1 — + // in the base pool or in an explosion spawn. + let mut app = seeded("", 11); + app.input = "10d6r1!".to_string(); + app.insta_roll(); + assert!(app.all_settled()); + assert!( + app.dice.iter().all(|d| d.final_value != 1), + "an animated r1 die landed on a 1" + ); + } + #[test] fn power_starts_at_zero_and_stays_in_unit_range() { let mut app = seeded("", 1); diff --git a/crates/tinhorn-core/src/parse.rs b/crates/tinhorn-core/src/parse.rs index 6ff495c..4184c30 100644 --- a/crates/tinhorn-core/src/parse.rs +++ b/crates/tinhorn-core/src/parse.rs @@ -12,6 +12,8 @@ //! Per-term modifiers (written immediately after the `dN`, in any order): //! "2d20kh1" -> keep the highest 1 (advantage); "kl1" keeps the lowest //! "4d6dl1" -> drop the lowest 1 (classic ability score); "dh1" drops highest +//! "4d6r1" -> reroll any 1 (repeats until it clears); a bare number means '=' +//! "d20r<3" -> reroll anything under 3; "ro" rerolls once; chain them, e.g. "6d6r2r4r6" //! "3d6!" -> exploding: a max face rolls another die (repeats, capped) //! "d10!>8" -> exploding on >8 instead of just the max face //! "4d6*2" -> multiply this term's kept sum by 2 @@ -28,6 +30,7 @@ //! term := [count] ('d'|'D') sides termmod* | integer //! sides := digits | '%' ('%' means 100) //! termmod := ('kh'|'kl'|'dh'|'dl') [n] +//! | ('r'|'ro') [ '>'|'<'|'=' ] n //! | '!' [ ('>'|'<'|'=') n ] //! | '*' n //! @@ -52,6 +55,17 @@ impl Compare { } } +/// A reroll rule: a die whose face matches `cmp` is thrown out and redrawn. A +/// plain `r` keeps redrawing until the face clears every reroll rule on the term +/// (capped so it always terminates); the `ro` form (`once`) redraws at most +/// once. The compare point reuses [`Compare`], so `r1` is `r=1` (bare number +/// means equality), `r<3` rerolls anything under 3, and `r>4` anything over. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Reroll { + pub cmp: Compare, + pub once: bool, +} + /// One modifier attached to a dice term, applied to that term's pool. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TermMod { @@ -59,6 +73,9 @@ pub enum TermMod { KeepLow(u32), DropHigh(u32), DropLow(u32), + /// Reroll matching dice before anything else touches the pool. Chained rules + /// (`r2r4r6`) each become one of these, in written order. + Reroll(Reroll), /// `None` means "explode on the max face" — resolved against `sides` at roll time. Explode(Option), /// Multiply this term's kept sum by the constant. @@ -320,6 +337,27 @@ fn parse_term_mods( continue; } + // Reroll: `r` redraws a matching die until it clears; `ro` + // redraws once. `` is a bare number (equality) or `<`/`>`/`=` and a + // number. Chain them (`r2r4r6`) to reroll several faces. Matched before + // the outer loop could read the bare `r` as an unexpected character. + if matches!(chars.get(*i), Some('r' | 'R')) { + let once = matches!(chars.get(*i + 1), Some('o' | 'O')); + *i += if once { 2 } else { 1 }; + let cmp = parse_reroll_compare(chars, i)?; + // A recurring reroll whose compare matches *every* face would never + // clear; the roll-time cap stops the loop, but reject the + // statically-degenerate case with a clear message (as the `d1!` + // guard does for exploding). A `ro` always terminates, so it's fine. + if !once && (1..=sides).all(|f| cmp.matches(f)) { + return Err(format!( + "that reroll never clears — every face of a d{sides} matches (use ro… to reroll once)" + )); + } + mods.push(TermMod::Reroll(Reroll { cmp, once })); + continue; + } + // Exploding: '!' optionally followed by a comparison. if *i < n && chars[*i] == '!' { *i += 1; @@ -382,6 +420,30 @@ fn parse_optional_count(chars: &[char], i: &mut usize) -> Option { chars[start..*i].iter().collect::().parse().ok() } +/// Parse a reroll compare point: an optional `>`/`<`/`=` then a *required* +/// number, defaulting to `=` (equality) when the operator is omitted — so `r1` +/// rerolls 1s, `r<3` rerolls anything under 3, and `r>4` anything over. +fn parse_reroll_compare(chars: &[char], i: &mut usize) -> Result { + let op: fn(u32) -> Compare = match chars.get(*i) { + Some('>') => { + *i += 1; + Compare::Gt + } + Some('<') => { + *i += 1; + Compare::Lt + } + Some('=') => { + *i += 1; + Compare::Eq + } + _ => Compare::Eq, + }; + let n = parse_optional_count(chars, i) + .ok_or_else(|| "expected a reroll target (e.g. r1 or r<3)".to_string())?; + Ok(op(n)) +} + /// Parse an optional explode comparison: `>N`, ` Result, String> { let op = match chars.get(*i) { @@ -550,6 +612,76 @@ mod tests { assert!(term(&r, 1).mods.is_empty()); } + #[test] + fn reroll() { + // A bare number is equality: reroll 1s. + let r = parse("4d6r1").unwrap(); + assert_eq!( + term(&r, 0).mods, + vec![TermMod::Reroll(Reroll { + cmp: Compare::Eq(1), + once: false + })] + ); + + // `ro` is reroll-once; comparisons work with or without an operator. + let r = parse("d20ro<3").unwrap(); + assert_eq!( + term(&r, 0).mods, + vec![TermMod::Reroll(Reroll { + cmp: Compare::Lt(3), + once: true + })] + ); + + // Chained compare points each become their own rule, in written order. + let r = parse("6d6r2r4r6").unwrap(); + assert_eq!( + term(&r, 0).mods, + vec![ + TermMod::Reroll(Reroll { + cmp: Compare::Eq(2), + once: false + }), + TermMod::Reroll(Reroll { + cmp: Compare::Eq(4), + once: false + }), + TermMod::Reroll(Reroll { + cmp: Compare::Eq(6), + once: false + }), + ] + ); + + // Reroll composes with the other modifiers in one term. + let r = parse("4d6r1kh3").unwrap(); + assert_eq!( + term(&r, 0).mods, + vec![ + TermMod::Reroll(Reroll { + cmp: Compare::Eq(1), + once: false + }), + TermMod::KeepHigh(3), + ] + ); + } + + #[test] + fn reroll_errors() { + assert!(parse("d6r").is_err(), "reroll needs a target"); + assert!(parse("d6ro").is_err(), "reroll-once still needs a target"); + assert!(parse("d6r<").is_err(), "a comparison needs a number"); + // A recurring reroll that matches every face would spin forever. + assert!(parse("d6r<7").is_err(), "r<7 rerolls every face of a d6"); + assert!(parse("d6r>0").is_err(), "r>0 rerolls every face"); + // …but a reroll-*once* of the same shape is fine — it always stops. + assert!(parse("d6ro<7").is_ok(), "ro clears after a single redraw"); + // A comparison that leaves faces alive is fine as a recurring reroll. + assert!(parse("d6r<3").is_ok()); + } + #[test] fn stacked_modifiers() { // explode, then keep the best 3 of whatever results, then double. diff --git a/crates/tinhorn/src/cli.rs b/crates/tinhorn/src/cli.rs index ce789e9..272231a 100644 --- a/crates/tinhorn/src/cli.rs +++ b/crates/tinhorn/src/cli.rs @@ -123,7 +123,13 @@ pub fn format_verbose(o: &Outcome) -> String { .dice .iter() .map(|d| { - let mut f = d.value.to_string(); + // Faces thrown out by a reroll lead the die, each tagged `r`: + // `1r4` rolled a 1, rerolled, and kept the 4. + let mut f = String::new(); + for prev in &d.rerolled { + let _ = write!(f, "{prev}r"); + } + let _ = write!(f, "{}", d.value); if d.exploded { f.push('!'); // spawned by an explosion } @@ -269,6 +275,48 @@ mod tests { panic!("no seed dropped a 20 under disadvantage"); } + #[test] + fn reroll_shows_discarded_faces_in_verbose_and_json() { + // Find a seed whose 12d6r1 actually rerolled at least one die, then + // check the discard shows up in both surfaces. + for seed in 0..80 { + let o = outcome("12d6r1", seed); + let Some(die) = o.terms[0].dice.iter().find(|d| !d.rerolled.is_empty()) else { + continue; + }; + // Every discarded face was a 1, and the surviving value is not. + assert!(die.rerolled.iter().all(|&v| v == 1)); + assert_ne!(die.value, 1); + + // JSON carries the discard list; verbose tags it with `r`. + let v: serde_json::Value = serde_json::to_value(&o).unwrap(); + let dice = v["terms"][0]["dice"].as_array().unwrap(); + assert!( + dice.iter().any(|d| d.get("rerolled").is_some()), + "JSON did not carry the rerolled faces" + ); + let text = format_verbose(&o); + assert!( + text.contains("1r"), + "verbose did not tag a rerolled face:\n{text}" + ); + return; + } + panic!("no seed rerolled a die"); + } + + #[test] + fn unrerolled_dice_omit_the_rerolled_key() { + // A die that never rerolled carries no `rerolled` field in JSON. + let v: serde_json::Value = serde_json::to_value(outcome("4d6", 1)).unwrap(); + for d in v["terms"][0]["dice"].as_array().unwrap() { + assert!( + d.get("rerolled").is_none(), + "empty reroll list leaked to JSON" + ); + } + } + #[test] fn verbose_marks_dropped_and_shows_total() { let o = outcome("4d6dl1", 7); diff --git a/crates/tinhorn/src/ui.rs b/crates/tinhorn/src/ui.rs index 8e1a3b3..71d1462 100644 --- a/crates/tinhorn/src/ui.rs +++ b/crates/tinhorn/src/ui.rs @@ -1611,19 +1611,19 @@ fn render_help_overlay(frame: &mut Frame, area: Rect, scroll: u16) -> u16 { syntax_row("2d20-1", "add or subtract a flat modifier"), Line::raw(""), heading("Keep / drop"), - syntax_row("2d20kh1", "advantage — keep the highest 1"), - syntax_row("2d20kl1", "disadvantage — keep the lowest 1"), + syntax_row("2d20kh1", "advantage — keep the highest 1 (kl1 = lowest)"), syntax_row("4d6dl1", "drop the lowest 1 (ability scores)"), syntax_row("4d6dh1", "drop the highest 1"), Line::raw(""), + heading("Reroll & explode"), + syntax_row("4d6r1", "reroll 1s until they clear (ro1 = once)"), + syntax_row("3d6!", "a max face rolls another die"), + syntax_row("d10!>8", "explode on any face over 8 (also !=N, ! 15", "beat a target (or 'vs'); < N rolls under"), syntax_row("4d6*2", "double this term's sum (modifiers stack)"), Line::raw(""), - heading("Exploding"), - syntax_row("3d6!", "a max face rolls another die"), - syntax_row("d10!>8", "explode on any face over 8 (also !=N, ! Date: Sat, 18 Jul 2026 19:10:47 -0700 Subject: [PATCH 2/2] docs: put reroll after exploding in help/README, restore disadvantage row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Order the notation reference by most common syntax — the Reroll section now follows Exploding in both the README and the help overlay. Restore the standalone `2d20kl1` disadvantage row that had been folded into the advantage line, trimming the redundant Separators hint so the help overlay still fits its 28-row budget. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 32 ++++++++++++++++---------------- crates/tinhorn/src/ui.rs | 11 ++++------- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 8df8dda..2bceec7 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ the call with `--graphics kitty` or `--graphics blocks`. A roll is a sequence of **dice terms** and optional **flat modifiers**, separated by `+`, `,`, whitespace, or simply written next to each other. A term -can carry **modifiers** (reroll, keep/drop, explode, multiply) written right +can carry **modifiers** (keep/drop, explode, reroll, multiply) written right after its `dN`; these apply in pool order — **reroll → explode → keep/drop → multiply** — and stack. Add **stakes** to check the total against a target. @@ -140,21 +140,6 @@ wedge the renderer. size. Dropped dice are still thrown and bounce around — you watch advantage discard the lower d20 — but they're rendered dimmed and left out of the total. -### Reroll - -| Input | Meaning | -| ---------- | ------------------------------------------------------- | -| `4d6r1` | reroll any 1, repeating until it clears | -| `d20ro1` | `ro` rerolls **once** — one redraw, then live with it | -| `d20r<3` | a compare point works too (`<`, `>`, `=`); bare `N` = `=N` | -| `6d6r2r4r6`| chain compare points to reroll several faces | - -A reroll throws the old face out before anything else touches the pool, so a -die you'd have dropped or exploded is settled first. The die lands on its -kept face in the arena; a `-v` breakdown shows what was tossed (`1r4` = rolled -a 1, rerolled, kept the 4). A plain `r` whose compare would match *every* face -is rejected (it could never clear); use `ro` if you really mean one redraw. - ### Stakes Call a target and the arena hands down a verdict — margin and all. At most one @@ -179,6 +164,21 @@ Exploding plays out live: a die that _settles_ on a qualifying face drops one more die into the arena, which can explode in turn — capped at 40 extra dice per term so `d2!` can't grow without bound. +### Reroll + +| Input | Meaning | +| ---------- | ------------------------------------------------------- | +| `4d6r1` | reroll any 1, repeating until it clears | +| `d20ro1` | `ro` rerolls **once** — one redraw, then live with it | +| `d20r<3` | a compare point works too (`<`, `>`, `=`); bare `N` = `=N` | +| `6d6r2r4r6`| chain compare points to reroll several faces | + +A reroll throws the old face out before anything else touches the pool, so a +die you'd have dropped or exploded is settled first. The die lands on its +kept face in the arena; a `-v` breakdown shows what was tossed (`1r4` = rolled +a 1, rerolled, kept the 4). A plain `r` whose compare would match *every* face +is rejected (it could never clear); use `ro` if you really mean one redraw. + ### Multiply | Input | Meaning | diff --git a/crates/tinhorn/src/ui.rs b/crates/tinhorn/src/ui.rs index 71d1462..a6ff955 100644 --- a/crates/tinhorn/src/ui.rs +++ b/crates/tinhorn/src/ui.rs @@ -1611,14 +1611,15 @@ fn render_help_overlay(frame: &mut Frame, area: Rect, scroll: u16) -> u16 { syntax_row("2d20-1", "add or subtract a flat modifier"), Line::raw(""), heading("Keep / drop"), - syntax_row("2d20kh1", "advantage — keep the highest 1 (kl1 = lowest)"), + syntax_row("2d20kh1", "advantage — keep the highest 1"), + syntax_row("2d20kl1", "disadvantage — keep the lowest 1"), syntax_row("4d6dl1", "drop the lowest 1 (ability scores)"), syntax_row("4d6dh1", "drop the highest 1"), Line::raw(""), - heading("Reroll & explode"), - syntax_row("4d6r1", "reroll 1s until they clear (ro1 = once)"), + heading("Explode & reroll"), syntax_row("3d6!", "a max face rolls another die"), syntax_row("d10!>8", "explode on any face over 8 (also !=N, ! 15", "beat a target (or 'vs'); < N rolls under"), @@ -1631,10 +1632,6 @@ fn render_help_overlay(frame: &mut Frame, area: Rect, scroll: u16) -> u16 { ), syntax_row("Tab", "cycle Enter's mode: shake → roll → insta"), syntax_row("Esc", "put them down. Power never touches the values."), - Line::from(Span::styled( - " Separators: + - , space or just write dice next to each other.", - Style::default().fg(Color::DarkGray), - )), close_hint(), ]; overlay_panel(frame, area, " 🎲 dice notation ", lines, scroll)