Skip to content
isRobertMaPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

diceforge

R-CMD-check

Monte Carlo probability engine for tabletop combat resolved as a chain of d6 rolls, written in strict R6 OOP.

Two principles drive the design:

  1. The rules change, so they are data. Everything edition-specific lives in a swappable Ruleset: the Strength-versus-Toughness table, the always-fails-on-1 convention, modifier caps, how AP interacts with an invulnerable save. None of it is inside a step class.
  2. Saves are fast rolled. A defending force is a roster of heterogeneous ModelGroups. The whole batch for one weapon profile is rolled at once, and the defender must declare a group order before seeing any dice. That ordering is a first-class policy object you can swap, script, or search over.

Contents


Installation

Prerequisites

Requirement Version Notes
R >= 4.1.0 uses the native |> pipe in places
R6 any the only hard dependency besides base
shiny any only for run_app()
testthat >= 3.0.0 only for the test suite

From GitHub

install.packages("remotes")
remotes::install_github("isRobertMa/diceforge")

From a local clone

git clone https://github.com/isRobertMa/diceforge.git
install.packages("C:/path/to/diceforge", repos = NULL, type = "source")

Or, from inside the package directory:

install.packages("devtools")
devtools::install()

Development setup

For working on the package rather than just using it:

install.packages(c("devtools", "testthat", "shiny"))
devtools::load_all(".")   # load without installing; the fastest edit loop
devtools::test()          # run the suite
devtools::check()         # full R CMD check, as CI runs it

load_all() is what you want while iterating. It sources everything in R/ into a namespace-like environment, so internal functions such as resolve_batch_fast() are callable directly without :::.

Verifying the install

library(diceforge)

simulate_attack(attacks = 15, skill = 3, strength = 4, toughness = 6,
                save = 4, damage = 1, n = 100000, seed = 1)$expected("damage_dealt")
#> [1] 1.66766        # closed form is 15/9 = 1.6667

Launching the app

diceforge::run_app()

Tabs for the attacker, a defender of up to three model groups, the ruleset and ordering, plus outputs for the distribution, the per-group breakdown, an exhaustive ordering comparison, and a die-level trace.

A note on NAMESPACE

This package's NAMESPACE is maintained by hand, not generated by roxygen. The roxygen comments in R/ are there for humans and for a future devtools::document() run, but nothing regenerates them today. If you export a new function or class, add the export() line to NAMESPACE yourself — a missing entry shows up as "could not find function" only once the package is installed, which is an annoying way to find out.


Quick start

library(diceforge)

# A unit that cannot be described by a single save characteristic.
u <- Unit$new(toughness = 5, groups = list(
  ModelGroup$new("invuln", count = 3, wounds = 3, save = 7, invuln = 4),
  ModelGroup$new("armour", count = 4, wounds = 3, save = 3)
))

atk <- Attacker$new(attacks = 20, skill = 3, strength = 8, ap = -3, damage = 2)

AttackSequence$new(atk, u)$compare_orderings(n = 20000, seed = 3)
         ordering mean_models_slain sd_models_slain  p_none p_wipe
1 invuln > armour           2.30520       1.4778573 0.04520 0.0026
2 armour > invuln           3.26405       0.8463235 0.00155 0.0000

Why the difference? AP-3 turns the 3+ armour save into a 6+ but cannot touch a 4+ invulnerable. explain_batch() shows it die by die:

explain_batch(u, dice = c(1,1,3,4,4,6), ap = -3, damage = 2,
              allocation = c("invuln", "armour"))
order: invuln > armour   (AP -3, damage 2)   left = invuln/armour
 die saved_by needs result damage_to slain left
   1   invuln    4+   FAIL    invuln     0  3/4
   1   invuln    4+   FAIL    invuln     1  2/4
   3   invuln    4+   FAIL    invuln     0  2/4
   4   invuln    4+   save         -     0  2/4
   4   invuln    4+   save         -     0  2/4
   6   invuln    4+   save         -     0  2/4

total failed: 3   models slain: 1

Lead with armour instead and the same six dice fail five times.


Project layout

diceforge/
├── DESCRIPTION            package metadata and dependencies
├── NAMESPACE              hand-maintained; add exports here
├── NEWS.md                changelog, breaking changes first
├── R/
│   ├── Ruleset.R          rules as data + presets
│   ├── ModelGroup.R       ModelGroup and Unit
│   ├── AllocationPolicy.R ordering policies + permutation search
│   ├── RollStep.R         abstract base for d6-vs-target stages
│   ├── HitStep.R          skill only
│   ├── WoundStep.R        S vs T via the ruleset's table
│   ├── SaveStep.R         sequential saves (legacy rulesets)
│   ├── BatchSaveStep.R    fast rolling; fuses saves and allocation
│   ├── DamageStep.R       damage allocation (sequential rulesets)
│   ├── AttackSequence.R   composition, simulation, ordering search
│   ├── SimResult.R        distributions and tail probabilities
│   ├── DiceExpr.R         "15", "D6", "2D6+1"
│   ├── DiceStream.R       the value flowing between steps
│   ├── Modifier.R         flat / conditional / stacked
│   ├── RerollPolicy.R
│   ├── Profiles.R         Attacker
│   ├── run_app.R
│   └── utils.R            internal helpers
├── inst/shiny/app.R       the application
├── tests/
│   ├── testthat.R
│   └── testthat/
│       ├── helper-diceforge.R   shared fixtures and expectations
│       └── test-*.R
└── .github/workflows/R-CMD-check.yaml

Core concepts

Class Needs Does not need
Ruleset — it is the rules
HitStep skill, modifier anything about the target
WoundStep Strength, Toughness, the ruleset's table anything about armour
BatchSaveStep the roster, AP, an ordering policy how the wounds arose
AllocationPolicy groups, the incoming weapon the dice
ModelGroup save, invuln, wounds, count, FNP the rest of the unit

RollStep is the abstract base. A subclass supplies target(ctx) and optionally emit(), which decides what happens to surviving dice. Roll conventions are not properties of a step — they come from ctx$ruleset.

Which steps exist is itself a function of the ruleset:

  • Batched (default): hit → wound → BatchSaveStep
  • Sequential: hit → wound → SaveStep → DamageStep

Under fast rolling, saves and damage allocation cannot be separate stages, because the save target depends on which group is still alive.


Writing tests

The suite is 354 tests across 8 files, and it is the part of this package most worth maintaining. Simulation code is uniquely easy to get subtly wrong in ways that still produce plausible-looking numbers, so the tests are structured to catch exactly that.

devtools::test()                          # everything
devtools::test(filter = "batch")          # just test-batch-saves.R
testthat::test_file("tests/testthat/test-ruleset.R")

Note that explain_batch() prints, so a full run is noisy. To get a clean summary:

sink(tempfile())
r <- testthat::test_dir("tests/testthat", package = "diceforge", reporter = "silent")
sink()
d <- as.data.frame(r)
cat("passed:", sum(d$passed), " failed:", sum(d$failed), "\n")

The four kinds of test, and when to use which

Pick the strongest one that applies. In order of preference:

1. Deterministic — no RNG at all

The best tests in the suite touch no dice. Anything about targets, plans, ordering or validation can be asserted exactly.

test_that("AP is applied to armour but never to an invulnerable save", {
  u <- mixed_unit()
  pl <- batch_plan(u, ap = -3, allocation = c("invuln", "armour"))
  expect_equal(pl$floors, c(4L, 6L))       # 4++ untouched; 3+ becomes 6+

  pl0 <- batch_plan(u, ap = 0, allocation = c("invuln", "armour"))
  expect_equal(pl0$floors, c(4L, 3L))      # with no AP, armour is better
})

batch_plan() (in helper-diceforge.R) resolves a BatchSaveStep's plan without running a simulation. If you can express your test this way, do.

2. Trace-based — explicit dice, deterministic outcome

For rules about sequencing — which group a die is saved against, when the target switches, where damage lands — feed explain_batch() a fixed vector of dice. There is no RNG, so the assertion is exact.

test_that("the save target only changes when a group is wiped out", {
  u <- Unit$new(toughness = 4, groups = list(
    ModelGroup$new("front", count = 1, wounds = 1, save = 2),
    ModelGroup$new("back", count = 5, wounds = 1, save = 6)
  ))
  tr <- explain_batch(u, dice = c(1, 2, 3, 4, 5, 6), ap = 0, damage = 1,
                      allocation = c("front", "back"))
  expect_equal(tr$saved_by[1], "front")
  expect_equal(tr$result[1], "FAIL")
  expect_true(all(tr$saved_by[-1] == "back"))
  expect_equal(unique(tr$needs[-1]), "6+")
})

This is the pattern to use for any new rule about ordering or allocation. It doubles as documentation: the returned data frame is the worked example.

Note explain_batch() applies damage deterministically and rolls no FNP.

3. Analytic — compare a simulation against a closed form

When every attack is independent, the sequence is Binomial and you can compute the answer exactly. Use expect_mc_equal(), which derives its tolerance from the simulation's own Monte Carlo error:

test_that("the plain chain matches the binomial closed form", {
  res <- simulate_attack(attacks = 15, skill = 3, strength = 4, toughness = 6,
                         save = 4, ap = 0, damage = 1, n = 200000, seed = 20260801)

  p <- p_pass(3) * p_pass(5) * (1 - p_pass(4, stage = "save"))
  expect_equal(p, 1 / 9)

  expect_mc_equal(res, "damage_dealt", 15 * p)
  expect_mc_equal(res, "hits", 15 * p_pass(3))
})

Do not hand-tune tolerances. expect_mc_equal() computes k * sd(v) / sqrt(n) from the result itself, so the band tightens automatically as you raise n and cannot silently pass because someone picked a slack number. k = 4 gives roughly a 1-in-16,000 spurious failure rate per assertion.

If you need n for a target relative tolerance, invert it:

n  >=  ( k * sd / (mean * tol) )^2

p_pass() is built on Ruleset$evaluate(), which is itself pinned by exact hand-written assertions in test-ruleset.R. Testing the primitive exactly and then building on it is deliberate — it keeps the arithmetic in one place.

The mistake to avoid. A "4+" passes on 4, 5, 6 — that is 3/6, not 4/6. Getting this wrong produces expectations that are wrong by ~33% and look entirely reasonable. Use p_pass() rather than writing fractions by hand.

4. Differential — fast path against a reference implementation

Where an optimisation exists, test it against the naive version rather than against hand-computed values. resolve_batch_fast() is closed-form; resolve_batch_scan() is an explicit die-by-die walk. They must agree:

test_that("the closed-form path agrees with the explicit scan", {
  set.seed(4242)
  for (k in 1:30) {
    # ... build a random roster, AP and damage ...
    fast <- resolve_batch_fast(counts, vals, tr, pl$groups, pl$floors, dmg, nt, FALSE)
    ref  <- resolve_batch_scan(counts, vals, tr, pl$groups, pl$floors,
                               dice(dmg), nt, ruleset_current(), FALSE)
    expect_equal(fast$failed, ref$failed, info = sprintf("k=%d", k))
    expect_equal(fast$damage_dealt, ref$damage_dealt, info = sprintf("k=%d", k))
    expect_equal(fast$models_slain, ref$models_slain, info = sprintf("k=%d", k))
  }
})

Both are fed the same dice, so this is exact, not statistical. Always pass info = so a failure tells you which iteration broke.

If you add a fast path, you must add it to this test. The closed-form resolver is only reached when damage is constant with no FNP, no redirects and no spilling; widening that condition without widening the fuzz test is how a silent wrong answer gets in.

Available helpers

tests/testthat/helper-diceforge.R is sourced automatically before every test file. It currently provides:

Helper Purpose
expect_mc_equal(res, measure, expected, k = 4) mean vs closed form, tolerance from MC error
expect_monotone_survival(res, measure) asserts P(>= x) never increases
mixed_unit() invuln + armour groups; the standard ordering fixture
screened_unit() bodyguard redirect fixture (Makari + Boyz shape)
open_unit() a unit too big to clip a measurement
p_pass(target, modifier, stage, ruleset) probability one die passes
batch_plan(unit, ap, allocation, ruleset) resolve targets without simulating

Add to this file rather than redefining fixtures per test file.

Seeding discipline

Every test that touches RNG must be deterministic:

  • Pass seed = to simulate() / simulate_attack().
  • Use set.seed() at the top of a loop that builds random fixtures.
  • When comparing two configurations, use the same seed for both so the comparison is paired and the difference is not swamped by noise. compare_orderings() does this internally.

Testing internal functions

test_dir(..., package = "diceforge") and devtools::test() both run tests in the package namespace, so internals like roll_dice(), resolve_batch_fast() and allocate_across_groups() are callable without :::. You only need diceforge::: in a scratch script outside the suite.

A worked example: adding a rule and its test

Say you add a rule where a group re-rolls its saving throws.

1. Extend the model. Add the field to ModelGroup and thread it through BatchSaveStep.

2. Write the deterministic test first — targets and plans, no dice:

test_that("a group's own reroll policy is recorded in the plan", {
  u <- Unit$new(toughness = 4, groups = list(
    ModelGroup$new("a", count = 3, wounds = 1, save = 4, save_reroll = "ones")
  ))
  pl <- batch_plan(u, ap = 0)
  expect_equal(pl$rerolls[[1]]$kind, "ones")
})

3. Then the analytic test, because rerolls have a closed form:

test_that("rerolling 1s on saves raises the pass rate correctly", {
  p <- p_pass(4, stage = "save")
  res <- # ... simulate ...
  expect_mc_equal(res, "unsaved", 20 * (1 - (p + (1/6) * p)))
})

4. Add the export to NAMESPACE if you introduced anything public, and a line to NEWS.md.

5. Run the whole suite, not just your file — the batch resolver is shared, and a change to it can move numbers three files away.

What is deliberately not tested

  • Exact RNG values. Tests assert distributions and invariants, never "die 3 was a 5". Swapping R's RNG must not break the suite.
  • The Shiny app's rendering. Server logic is exercised via shiny::testServer() in a scratch script; the UI itself is not snapshot-tested.
  • Performance. Timings are in this README, not in assertions, because CI machines vary too much for a wall-clock test to be meaningful.

Roadmap: SQL datasheet ingestion

The intended next step is bulk ingestion of 40k 11th edition datasheets and simulation across many matchups at once. The architecture already anticipates this in one important way: an edition is a Ruleset value, not a code change. Whatever 11th edition does to the wound table, modifier caps or save conventions, it is a constructor argument.

I don't know 11th edition's rules — define them in a ruleset_11e() and the rest of the engine follows.

Why a database at all

A datasheet corpus is naturally relational: units have model groups, model groups have profiles, units have many weapons, weapons have many keywords. Holding that as nested R lists gets unmanageable fast, and the interesting queries ("every weapon that wounds T12 on 3+ or better") are joins.

Suggested schema

CREATE TABLE units (
  unit_id      INTEGER PRIMARY KEY,
  name         TEXT NOT NULL,
  faction      TEXT,
  toughness    INTEGER NOT NULL,
  edition      TEXT NOT NULL          -- '11e'; lets editions coexist
);

CREATE TABLE model_groups (
  group_id     INTEGER PRIMARY KEY,
  unit_id      INTEGER NOT NULL REFERENCES units(unit_id),
  name         TEXT NOT NULL,
  count        INTEGER NOT NULL,
  wounds       INTEGER NOT NULL,
  save         INTEGER NOT NULL,
  invuln       INTEGER,               -- NULL for none
  fnp          INTEGER,
  damage_to    TEXT,                  -- bodyguard redirect, by group name
  UNIQUE (unit_id, name)
);

CREATE TABLE weapons (
  weapon_id    INTEGER PRIMARY KEY,
  unit_id      INTEGER REFERENCES units(unit_id),
  name         TEXT NOT NULL,
  attacks      TEXT NOT NULL,         -- dice expression: '15', 'D6', '2D6+1'
  skill        INTEGER NOT NULL,
  strength     INTEGER NOT NULL,
  ap           INTEGER NOT NULL,
  damage       TEXT NOT NULL          -- dice expression
);

CREATE TABLE weapon_keywords (
  weapon_id    INTEGER NOT NULL REFERENCES weapons(weapon_id),
  keyword      TEXT NOT NULL,         -- 'lethal hits', 'sustained hits 2', ...
  PRIMARY KEY (weapon_id, keyword)
);

CREATE TABLE sim_results (
  run_id       INTEGER PRIMARY KEY,
  weapon_id    INTEGER NOT NULL,
  unit_id      INTEGER NOT NULL,
  ruleset      TEXT NOT NULL,
  allocation   TEXT NOT NULL,         -- the declared ordering
  n_trials     INTEGER NOT NULL,
  seed         INTEGER,
  mean_slain   REAL, sd_slain REAL, p_wipe REAL,
  profile_hash TEXT NOT NULL          -- invalidates the cache on any change
);
CREATE INDEX ix_results ON sim_results (weapon_id, unit_id, ruleset, allocation);

Two design notes worth keeping:

  • edition on units, so 10th and 11th edition data can live side by side and be simulated under their respective rulesets in the same session.
  • profile_hash on sim_results, a digest of the attacker profile, the unit roster, the ruleset and the policy. Simulation is expensive and results are cacheable, but only if you can tell when an input changed.

Which database

DuckDB. The workload is "write a few million simulation result rows, then aggregate them repeatedly" — columnar, analytical, single-user. That is exactly what DuckDB is for, it reads and writes R data frames and Parquet directly with almost no marshalling cost, and it needs no server.

SQLite is the right answer only if you need concurrent writers or want the file readable by tooling that already speaks SQLite.

install.packages(c("DBI", "duckdb", "dbplyr"))
con <- DBI::dbConnect(duckdb::duckdb(), "datasheets.duckdb")

The adapter layer

This is the only new code the package needs — a thin translation from rows to objects. It belongs in a new R/sql.R, and it should be only translation: no rules logic, which stays in Ruleset.

#' Build a Unit from its rows.
unit_from_rows <- function(unit_row, group_rows) {
  Unit$new(
    name = unit_row$name,
    toughness = unit_row$toughness,
    groups = lapply(seq_len(nrow(group_rows)), function(i) {
      g <- group_rows[i, ]
      ModelGroup$new(g$name, count = g$count, wounds = g$wounds,
                     save = g$save, invuln = g$invuln, fnp = g$fnp,
                     damage_to = g$damage_to)
    })
  )
}

#' Build an Attacker from a weapon row plus its keywords.
attacker_from_rows <- function(weapon_row, keywords = character()) {
  Attacker$new(
    attacks = weapon_row$attacks, skill = weapon_row$skill,
    strength = weapon_row$strength, ap = weapon_row$ap,
    damage = weapon_row$damage, keywords = keywords,
    lethal_hits = "lethal hits" %in% keywords,
    devastating_wounds = "devastating wounds" %in% keywords,
    sustained_hits = parse_sustained(keywords)   # "sustained hits 2" -> 2
  )
}

Keywords are the fiddly part: they are free text on a datasheet but flags and dice expressions in the engine. Keep that parsing in one function with its own exhaustive test file, because it is where the errors will be.

Batch simulation

simulate_matchups <- function(con, weapon_ids, unit_ids,
                              ruleset = ruleset_11e(), n = 20000) {
  grid <- expand.grid(weapon_id = weapon_ids, unit_id = unit_ids)
  rows <- lapply(seq_len(nrow(grid)), function(i) {
    atk  <- attacker_from_db(con, grid$weapon_id[i])
    unit <- unit_from_db(con, grid$unit_id[i])
    sq   <- AttackSequence$new(atk, unit, ruleset = ruleset)
    # The ordering is the defender's decision, so take their best one.
    cmp  <- sq$compare_orderings(n = n, seed = 1L)
    data.frame(weapon_id = grid$weapon_id[i], unit_id = grid$unit_id[i],
               best_order = cmp$ordering[1], mean_slain = cmp[[2]][1])
  })
  DBI::dbWriteTable(con, "sim_results", do.call(rbind, rows), append = TRUE)
}

Taking compare_orderings()[1, ] per matchup is the honest default: it scores a weapon against a competently played defender rather than one who declared a bad order.

Testing the SQL layer

Test it against an in-memory database created and torn down per test — never against a real corpus, which makes tests slow and non-hermetic:

local_db <- function(env = parent.frame()) {
  con <- DBI::dbConnect(duckdb::duckdb(), ":memory:")
  DBI::dbExecute(con, schema_sql())
  withr::defer(DBI::dbDisconnect(con, shutdown = TRUE), env)
  con
}

test_that("a two-group unit round-trips through the database", {
  con <- local_db()
  write_unit(con, mixed_unit())
  back <- unit_from_db(con, 1L)
  expect_equal(back$group_names(), mixed_unit()$group_names())
  expect_equal(batch_plan(back, ap = -3)$floors,
               batch_plan(mixed_unit(), ap = -3)$floors)
})

Round-tripping and then asserting on the resolved plan rather than on field equality is the strong version of this test: it proves the reconstructed object behaves identically, not merely that it looks similar.

Add DBI, duckdb and withr to Suggests, and guard the tests with skip_if_not_installed("duckdb") so the core suite still runs without them.


Design notes

How it stays fast

Trials are never looped over. All trials' dice live in one flat vector tagged by trial index (DiceStream), so each step is a single sample.int() call.

The batch resolver exploits the order statistic: pass/fail is monotone in the die value, so failing dice are always a prefix of the sorted batch. A group's absorbed failures and the handoff point to the next group are therefore closed-form, and the resolver loops over groups (2–5), never over trials. The sorted array is never even materialised — only the count of failures per trial.

A test in test-ruleset.R asserts that monotonicity across every target and modifier, because the prefix argument is unsound without it.

100,000 trials of a 20-attack sequence into a two-group unit: ~1.2–1.6s on the closed-form path.

Rule exceptions without special cases

Devastating wounds are not special-cased anywhere. A wound that skips its save is injected into the batch as a die of value 0 — it fails every possible target, and because the batch sorts ascending it lands at the front, against the leading group. Lethal hits use the same per-die skip counter to bypass the wound roll.

Rules as data

ruleset_current()            # fast-rolled saves, ascending order statistic
ruleset_sequential_saves()   # the pre-fast-roll convention, for comparison

Ruleset$new(
  wound_table    = function(s, t) if (s >= 2 * t) 2L else if (s > t) 3L else 4L,
  effective_save = function(armour, invuln, ap) ...,
  stage_rules    = list(hit = list(auto_pass_on_crit = FALSE)),
  modifier_cap   = NULL,        # lift the +/-1 clamp
  save_sort      = "descending",
  damage_spills  = TRUE
)

Because both save conventions ship, you can measure what fast rolling costs the defender on identical profiles:

b <- AttackSequence$new(atk, u, ruleset = ruleset_current())$simulate(n = 30000, seed = 5)
s <- AttackSequence$new(atk, u, ruleset = ruleset_sequential_saves())$simulate(n = 30000, seed = 5)
c(batched = b$expected("models_slain"), sequential = s$expected("models_slain"))
#>    batched sequential
#>      2.291      1.972

Bodyguard redirects

ModelGroup$new("makari", count = 1, wounds = 4, save = 7, invuln = 2,
               damage_to = "boyz")

Failed saves deal their damage to the named group instead. The redirecting model keeps absorbing dice for as long as the receiver has models left, so a very good save in front of a large cheap group converts the whole batch into failures only on the values its save cannot stop. Damage falls back to the redirecting group once the receiver is wiped out.

Conditional modifiers

ConditionalModifier$new(
  when  = function(ctx) ctx$toughness >= 8,
  then  = 1,
  label = "target is T8+"
)

Anything in a profile's extra list joins the context, as do weapon and unit keywords, so modifiers can key off your own conditions.

Extending the chain

MortalStep <- R6::R6Class("MortalStep", inherit = RollStep, public = list(
  threshold = 5L,
  initialize = function(threshold = 5L, ...) {
    super$initialize(name = "mortal", ...)
    self$threshold <- threshold
  },
  target = function(ctx) self$threshold
))

seq$steps <- append(seq$steps, MortalStep$new(), after = 1)

It inherits the ruleset's conventions automatically; an unrecognised stage name falls back to the hit conventions. Anything that must skip a later stage sets skip in emit(). Anything that depends on the matchup reads it off ctx.

New ordering heuristics are AllocationPolicy subclasses, or just CustomOrder$new(function(groups, ctx) ...).


License

MIT. See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages