Skip to content

Latest commit

 

History

9 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

RollCall

A combat-planning and simulation tool for tabletop Dungeon Masters. Save a roster of player characters, build a library of monsters, assemble an encounter from both, and simulate the fight — turn order, hit/miss rolls, damage, deaths — before running it live at the table.

Rather than eyeballing whether an encounter is fair, a DM can Monte Carlo it: run the same fight a few hundred times and see the actual win rate, average round count, and average survivors on each side.

Table of contents

Features

  • Auth — registration and login (Spring Security, BCrypt-hashed passwords), every resource scoped to its owner.
  • Tables — save a roster of player characters with a full 5e-style stat block (HP, AC, initiative, attack modifier, damage range).
  • Monster library — build a reusable set of monster templates once, reuse them across encounters.
  • Encounter builder — assemble a fight from any Table + any monsters in your library.
  • Single-run simulation — roll a full fight turn by turn and get back a complete replay log: every attack roll, hit or miss, damage dealt, and death, in order.
  • Monte Carlo simulation — run the same encounter N times and get aggregate stats: win rate per side, draw rate, average round count, average survivors per side. This is what turns "will my party survive this?" into an actual answer instead of a guess.

Tech stack

Layer Choice
Language / runtime Java 17
Framework Spring Boot 3.3
Web Spring MVC + server-rendered Thymeleaf views
Persistence Spring Data JPA + PostgreSQL
Schema management Flyway (versioned SQL migrations; Hibernate never mutates schema)
Auth Spring Security (form login, BCrypt)
Build Maven
Local infra Docker Compose (Postgres only — the app itself runs natively for fast IDE hot-reload)

Getting started

Prerequisites: Java 17, Maven, Docker Desktop running.

cd dm-toolkit
mvn spring-boot:run

Spring Boot's Docker Compose integration notices docker-compose.yml, starts Postgres, waits for it to be healthy, then boots the app on localhost:8080. Stopping the app stops the container too — no manual docker compose up.

Visit http://localhost:8080/register to create an account, then sign in at /login.

# run the test suite
mvn test

# run one test class or method
mvn test -Dtest=SimulationServiceTest
mvn test -Dtest=SimulationServiceTest#simulateEncounterOnce

Architecture

A few decisions worth calling out, since they shaped the rest of the codebase:

Combat stats live in one shared superclass, not a shared table. Combatant is a JPA @MappedSuperclass — not an @Entity — holding the common stat block (name, maxHp, initiativeModifier, ac, attackModifier, dprLow, dprHigh). PlayerCharacter, MonsterTemplate, and EncounterParticipant each extend it and get their own table via field-folding, rather than one polymorphic table with a discriminator column. The three represent genuinely different things (a saved character, a reusable template, a frozen combat snapshot) that happen to share a stat shape — inheritance-via-mapped-superclass captures that without forcing an artificial "is-a" relationship at the database level.

An encounter is a photograph, not a window. When a player character or monster template is added to an Encounter, its stats are copied into a new EncounterParticipant row — not linked by a live foreign key. sourcePlayerCharacterId / sourceMonsterTemplateId are kept as soft "copied from" pointers (ON DELETE SET NULL) but the simulation engine never reads them. This is deliberate: editing a roster character's AC next week must never retroactively change a fight — or its simulation results — from last week.

Ownership is enforced once, at the service layer. Every Roster, MonsterTemplate, and Encounter lookup goes through a getOwned(id, currentUser)-style method that scopes the query to owner_id == currentUser.id and throws a single NotFoundException for both "doesn't exist" and "exists but isn't yours" — so a user can't distinguish someone else's ID from a nonexistent one. Controllers never call a repository directly.

The dice are behind an interface. SimulationService depends on a DiceRoller interface, not Math.random() directly. Production wires up RandomDiceRoller; tests wire up a FakeDiceRoller that plays back a fixed, queued sequence of rolls. That makes the combat engine's turn resolution — target selection, attack rolls, damage, death — assertable deterministically ("given this exact roll sequence, the goblin dies on round 2") instead of only checkable statistically.

Schema changes go through Flyway, never Hibernate. Hibernate is configured with ddl-auto: validate — it checks entities against the schema and fails fast on drift, but it never creates or alters tables. Every schema change is a new versioned migration in db/migration/.

Project structure

Packages are organized by domain, not by layer — each package holds its own entities, repositories, services, and controllers together, rather than a top-level entity/ / repository/ / controller/ split:

com.dmtoolkit
├── user/          User entity, repository, UserDetailsService, principal
├── roster/        Roster ("Table") + PlayerCharacter, CRUD
├── encounter/      MonsterTemplate, Encounter, EncounterParticipant, CRUD
├── simulation/     SimulationService, DiceRoller, result/replay records
├── common/         Combatant (shared stat block), NotFoundException
├── config/         SecurityConfig
└── web/            Pages with no single owning domain (home, registration)

The simulation engine is the centerpiece:

SimulationService
├── simulateOnce(Encounter)        → SimulationResult (full round-by-round replay)
└── simulateMany(Encounter, N)     → MonteCarloResult (win rates, averages over N trials)

simulateOnce() rolls initiative once per combatant, then loops rounds (capped at 50, in case independent random targeting produces a genuine stalemate) until one side is wiped out. Each living combatant, in initiative order, picks a random living target on the opposing side (not focus-fire or lowest-HP targeting — a deliberate choice to keep the model simple), rolls an attack against the target's AC, and on a hit rolls damage uniformly across the attacker's damage range. Every roll is recorded, so the UI renders a full turn-by-turn story, not just a winner. simulateMany() runs that N times and aggregates the outcomes — because targeting is random, those aggregate numbers are only meaningful with genuine repeated sampling, not a shortcut analytical formula.

Testing

  • Combat resolution (SimulationService) is unit-tested against FakeDiceRoller, a test double that plays back a queued, fixed sequence of rolls — so a whole fight's outcome can be scripted and asserted exactly.
  • RandomDiceRoller, the production implementation, is tested statistically (range checks over many rolls) since its output is intentionally non-deterministic.
  • Service-layer tests (EncounterServiceTest, RosterServiceTest) run against a real Postgres instance via @DataJpaTest, not a mocked repository — the ownership-scoping logic is exactly the kind of thing that looks correct against a mock and breaks against a real query.

Roadmap

Not yet built, deliberately unscoped:

  • Encounter difficulty rating
  • Monster library sharing/import between DMs
  • Session notes tied to a specific encounter

Deliberately out of scope, not oversights: status conditions and environmental/terrain factors. Keeping the combat model to core stats (HP, AC, initiative, attack, damage) was a scoping choice to keep the simulation engine's logic tractable, not a limitation to work around.

About

Plan D&D encounters, then simulate them. Java/Spring Boot app running agent-based combat trials to predict outcomes — Monte Carlo, not guesswork.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages