dInfinity

Statistics

Design: statistics (option 1w), history (1x), per-set filtering (5b–5c), sessions (6c) and saved-roll statistics (8b) are in the clickable design (design/).

Players care about how their dice behave even though every face is equally likely. dInfinity records enough to answer “how many natural 20s have I rolled this campaign?” without turning into a spreadsheet.

What is recorded

Every completed roll writes one RollHistoryRow (roll_history) and updates aggregate counters. Rolls that are cancelled (app closed mid-tumble) are discarded.

Per die (keyed by setId + dieId)

Dice with duplicated face values (a d6 labelled 1,2,3,1,2,3) count by face value, so lowest/highest are 1 and 3.

Dropped dice (from kh/dl etc.) are still counted in the per-die stats — the die was thrown and landed on that face — but flagged in the history row.

A die that landed cocked or on another die and was thrown again by the player’s next shake (physics-and-rendering.md, “Avoiding stacked and cocked dice”) is counted once, on the face it was read on. The landing that could not be read has no face, so there is nothing to count for it — the rule “every throw the die landed on a face is counted” gives the answer by itself. A roll that waited for that shake is written down once, when its last die is read, and its history row counts the dice thrown again in its anomalies column, as it did when the roll threw them itself.

A die thrown again by hand

A player can pick a die of a landed roll up and throw it again with the next shake (physics-and-rendering.md, “Picking a die up and throwing it again”). Every throw of the die is counted, and the roll keeps one total. The roll is written down when it first lands, every die counted once. A throw by hand then amends that same history row — the new total, and the breakdown with the replaced face struck through beside the new one — and counts only the dice thrown again, once more each, on the face they came down on. A d6 that went 6, 6, 6, 4 has four readings in its own figures, because it landed four times, and the history has one roll.

The struck-through faces keep the die’s index in the breakdown, so a history row read back says which die was thrown again and what it showed before. The row keeps its time, session, saved roll and seed: it is the same roll, finished again. A row that has gone by the time the throw lands — pruned, or forgotten with its session — is not written back, and the new throws are still counted, because they still happened (StatisticsRepository.amend).

Per standard die type (aggregated across sets)

The same counters rolled up by sides, so “all my d20s” is one line even if some rolls used the brass set and some the doodled one. Sets with non-standard dice appear only in the per-die view.

Throws, totals and squares add up, so a pooled mean is the mean of everything thrown rather than the mean of two means. Streaks do not: a streak is a run within one die’s own sequence, and two dice’s runs do not join end to end, so a rolled-up row claims none.

The fair line is weighted by how often each die was thrown (FaceHistogram.ofPool). Two sets’ d20s need not be labelled the same way and, more to the point, need not have been thrown the same number of times: a fair d6 thrown a thousand times pooled with a 1,2,3,1,2,3 thrown twice is very nearly a fair d6, and a line drawn from the two value lists alone would call it loaded. Each die expects throws / sides of its throws on each of its faces; those expectations are summed per value and divided by the whole pool.

If any die in the pool comes from a set that is not installed any more, its throws are counted in the bars while its faces are missing from the line, so the pool is marked the same way a single uninstalled die is: the line is a guess, and the screen says so.

Where the rolls are going

The menu’s header names the session new rolls are filed under, beside the app’s own name (design/dInfinity.dc.html, option 1q). It says so only once there is more than one session to be in: every install starts with exactly one — the one the rolls made before anybody thought about sessions belong to — and naming it would be a line that never changes and never tells anybody anything.

The menu rather than the roll screen, for the same reason it is a preference at all: it outlives every screen, nothing on the tray should have to carry it, and the menu is where a player already looks to find out where they are.

Filtering what you are looking at

The history can be cut two ways: to one session — which is what sessions are for — or to one saved roll, wherever its throws were made. They are not combined: “Fireball on Tuesday” is a report rather than a list, and offering it would put two choosers on a screen whose whole job is to be scrollable.

The chooser is not drawn until there is more than one thing to choose between.

A filter that leaves nothing shows that, and not the message for a history with nothing in it: “you have never rolled anything” is wrong and discouraging in front of somebody who has rolled hundreds of times and picked a quiet session. Changing the filter closes any open breakdown, because the row that was open is not the row under the finger in the next list.

The statistics screen can be cut to a session too, and that chooser is not exclusive with the set one — see “Per session” below for what a session’s numbers are, and the one number a session cannot have.

Per saved roll and per group

A throw carries the saved roll it came from and the group that roll is in, recorded with it. That is what makes “Thorin’s attack rolls this campaign” a query rather than a guess — and without it every throw belongs to nothing and these screens have nothing to count.

The attribution is dropped by any edit. A formula that arrived from the strip and has since been typed over, or had a die tapped onto it, is not that roll’s throw: the picker goes through the same type a keystroke does, so it loses the attribution the same way. A saved roll’s record is what was thrown as that saved roll, not what was thrown starting from it.

Only the throws made through the saved roll are counted. The same formula typed by hand is a different question — it is not that roll’s record — and the history can tell them apart because a throw started from a saved roll carries its id.

The screen refuses to draw a conclusion. A mean two tenths above expectation is remarkable after ten thousand throws and nothing at all after four, so the drift is divided by the standard error of the mean (σ/√n) before anything is said about it. Below twenty throws it says there are too few to judge; past two standard errors it says worth a look, which is a long way from loaded. Dice are not accused on the strength of forty throws.

A formula that no longer graphs keeps its bars and loses its marks. A saved roll’s formula is stored as text and the set it names can be uninstalled afterwards (docs/dice-notation.md), so a roll that graphed last week may not today. What the dice did is still the player’s record and hiding it would lose the only copy — so the ink bars are drawn with nothing to draw them against, and the screen says which of the three things went wrong: the formula no longer parses, the set is not installed, or the throw is too large to compute exactly. They are different problems and one message for all three would be wrong about two of them.

Per session

A session is a user-defined bucket (“Tuesday campaign”). Every roll is filed under the active one, which is chosen on the Sessions screen and remembered with the settings; the menu’s header names it once there is more than one to be in. The first session is called “First rolls” and cannot be deleted — it is where the rolls made before anybody thought about sessions belong, and where the rolls of a deleted session go.

And where a roll goes when the active session is not there any more. Which session is active is a preference, and a preference outlives the thing it names: deleting the active session on the Sessions screen puts the setting right, but a session deleted while another screen is in front — or one already gone when the app was last opened — would otherwise leave every throw filed under an id nothing can find. Such a roll would be in the history and in the face counts and visible in neither, because both are read through the list of sessions. So the session is checked where the roll is recorded, against the sessions there actually are.

The history and the statistics can both be cut to a session. On the statistics screen the chooser sits above the set chooser rather than in it, because the two cut across each other: “the brass d20, this campaign” is a sentence a player would say, where “all my d20s, but only the brass ones” is not. It is not drawn until there are two sessions to choose between.

die_stats carries the session; die_summary does not. The counts are stored rather than recomputed — reading a campaign’s histogram out of roll_history.breakdown_json is a scan of up to fifty thousand rows every time the screen draws, and a row per face per die per session buys a lookup instead. The asymmetry between the two tables is the streaks:

The export is the whole record whatever is on screen, for the same reason: a file of what every die has done is not where anybody looks for one campaign, and a file written while a session was chosen would carry runs of zero.

A session with nothing in it says that, and not the message for somebody who has never rolled anything — the same rule the history follows.

Anomalies (debug)

Forced settles and post-rest corrections — both of which should never happen — with the seed and the counters of the throw that produced them (see docs/physics-and-rendering.md, “Debug tooling”). Behind the developer toggle, which is off on every install.

It does not travel with the statistics export. It is shared as plain text from the developer screen and from nowhere else. The exports here are built from HistoryEntry, which has no seed on it and cannot grow one; an anomaly carries a seed because reproducing the roll is the whole point of writing it down. One share path that could carry either would be the place the two got mixed up, so there are two, and only one of them is reachable with the toggle on (docs/architecture.md, decisions 13 and 56).

Screens

Storage

Room database. Version 1 is these three tables and nothing else; saved rolls, sessions and the installed-set registry arrive with the screens that need them (docs/TODO.md, Step 4), each as a migration. That is what “migrations from day one” buys: a database that has only ever been created and never migrated is one whose first migration gets written under pressure.

Every version’s schema is exported to data/schemas/ and checked in, and SchemaTest asserts there is one for every version and a migration for every step between them — so a version bump without a migration fails the build rather than a player’s phone. MigrationTest goes further and runs them: it builds a database out of version 1’s own exported schema, puts a roll in it, and opens it through the real builder, which is where Room refuses a migrated schema that does not match the entities. There is no destructive fallback: a player’s natural-20 count is not something to throw away because a schema moved.

-- version 1
roll_history(id, timestamp, session_id, saved_roll_id?, group_id?, formula, total,
             seed, input_blob, breakdown_json, anomalies)
die_stats(set_id, die_id, sides, face_value, count, dropped_count,
          PRIMARY KEY(set_id, die_id, face_value))   -- re-keyed in v5
die_summary(set_id, die_id, sides, throws, sum, sum_sq, hi_streak, hi_streak_max,
            lo_streak, lo_streak_max, last_rolled_at)

-- version 2 (docs/dice-notation.md, "Saved rolls")
saved_roll_group(id, name, icon, parent_id?, sort_order, table_set_id?, table_id?)
saved_roll(id, group_id, name, formula, icon, colour_argb?, favourite,
           table_set_id?, table_id?, created_at, last_used_at?, use_count)
                                                     -- re-shaped in v6

-- version 3 (per session, below)
session(id, name, started_at)

-- version 4 (docs/dice-sets.md, design 5a)
installed_set(id, enabled)

-- version 5 (per session, above): die_stats grows a session and is re-keyed
die_stats(set_id, die_id, session_id, sides, face_value, count, dropped_count,
          PRIMARY KEY(set_id, die_id, session_id, face_value))

-- version 6 (docs/dice-notation.md, "Saved rolls"): pinning goes, the
-- player's own order takes its place
saved_roll(id, group_id, name, formula, icon, colour_argb?, sort_order,
           table_set_id?, table_id?, created_at, last_used_at?, use_count)

Version 5 is the first migration to reshape a table somebody already has rows in, so it is the dance SQLite requires for a new primary key: build the table beside the old one, copy the rows across, drop the old one, rename. Every existing row becomes a row of the first session — not a default standing in for something unknown, but because the rolls those counts came from are already filed there in roll_history, so it is the same answer written in a second place. die_summary is untouched.

Version 6 is the same dance for the same reason: favourite goes and sort_order arrives in its place. The translation is done once, in the migration, and the order it writes is the order the list was in before it — favourites first, then by recent use — numbered per group. After that nothing computes an order again; it is a column, and only a drag changes it (docs/dice-notation.md, “Saved rolls”).

installed_set is the one table that is not a list of anything. Which dice sets exist is the dicesets/ folder’s answer, read and revalidated on every reading (docs/dice-sets.md); this table holds only what the player has decided about one, so a set with no row is enabled. Rows are pruned against what is actually on disk, because a folder can vanish without the app being asked and a row left behind would switch a new package off the moment somebody installed one under the same id.

A saved roll’s formula is stored as text. The dice set it names may be uninstalled later, and a roll that no longer resolves is neither deleted nor rewritten — storing anything more resolved would let an uninstall quietly rewrite what somebody wrote (docs/dice-notation.md).

Deleting a group moves its rolls to Unfiled and lifts its child groups to the top level rather than cascading. The foreign key cascades, and that is the floor under the behaviour rather than the behaviour: a group is a folder, and removing a folder should not remove what somebody put in it.

Streaks and sums are updated in the same transaction as the history insert. roll_history is capped at 50,000 rows by default (oldest pruned); aggregates are never pruned. Uninstalling a dice set keeps its rows.

breakdown_json carries the numbers the formula added as well as the dice. A roll recorded before they were written down has none, and that is the truth about it rather than a gap to paper over: nobody knows what an old 3d6 + 4 added, because at the time nothing wrote it down. Its rows will not add up to its total, exactly as they did not before — inventing the difference would be the app putting a number in front of somebody that it guessed. A formula that added nothing writes no key at all, so a roll with no modifiers is byte for byte the row it has always been.

seed is kept so a roll can be reproduced when a bug report needs it. It is internal: no screen shows it, and the export leaves it out. Reproducing a stored roll is a developer action (docs/physics-and-rendering.md, Debug tooling), not a feature of the app.

input_blob was meant for the shake that drove a roll, and nothing writes it. A shake-driven throw’s record — the throw’s spec with the samples that actually arrived written into it — is real and is handed out by the roll screen, and it stops there: RollRecording.record has no parameter it could be passed as, and FinishedRoll, RollHistoryRow and HistoryEntry have no field that could hold one. That is asserted in :app, which is the one module that can see both ends of the seam. A past roll is a record, not something to re-run (docs/architecture.md, decision 13; docs/physics-and-rendering.md, “Shake input”). The column stays because dropping it is a migration for nothing.

Export and reset