Monte Carlo probability engine for tabletop combat resolved as a chain of d6 rolls, written in strict R6 OOP.
Two principles drive the design:
- 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. - 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.
- Installation
- Quick start
- Project layout
- Core concepts
- Writing tests ← read this before adding features
- Roadmap: SQL datasheet ingestion
- Design notes
| 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 |
install.packages("remotes")
remotes::install_github("isRobertMa/diceforge")git clone https://github.com/isRobertMa/diceforge.gitinstall.packages("C:/path/to/diceforge", repos = NULL, type = "source")Or, from inside the package directory:
install.packages("devtools")
devtools::install()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 itload_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 :::.
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.6667diceforge::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.
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.
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.
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
| 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.
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")Pick the strongest one that applies. In order of preference:
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.
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.
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.
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.
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.
Every test that touches RNG must be deterministic:
- Pass
seed =tosimulate()/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.
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.
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.
- 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.
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.
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.
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:
editiononunits, so 10th and 11th edition data can live side by side and be simulated under their respective rulesets in the same session.profile_hashonsim_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.
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")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.
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.
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.
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.
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.
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.972ModelGroup$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.
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.
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) ...).
MIT. See LICENSE.