24 KiB
Rolemaster Automatic Retry
This ExecPlan is a living document. The sections Progress, Surprises & Discoveries, Decision Log, and Outcomes & Retrospective must be kept up to date as work proceeds.
PLANS.md is checked into the repo at PLANS.md. This document must be maintained in accordance with that file.
Purpose / Big Picture
After this change, a Rolemaster skill can opt into an automatic retry when its first result lands in specific retry bands. The player will be able to toggle that behavior while creating or editing a Rolemaster open-ended skill, roll the skill, and then see the retry clearly in the campaign log card through a special badge and readable summary text. The detailed roll view will still show enough information to explain why the retry happened and what final result was recorded.
For this feature, an eligible retry result means a Rolemaster open-ended percentile skill roll whose first fully evaluated result, including the skill expression modifier and any low-end subtraction chain, lands in one of the retry windows before any retry bonus is applied. This plan preserves the user-provided thresholds exactly: results 77 through 90 grant a retry with +5; results 91 through 110 grant a retry with +10.
Progress
- (2026-04-04 23:52Z) Reviewed
PLANS.mdand the current Rolemaster roll, skill-form, API, and log-card code paths. - (2026-04-04 23:52Z) Authored this ExecPlan in
TASKS.md. - (2026-04-14 20:45Z) Added persisted
RolemasterAutoRetrywiring through the skill model, API contracts, DTOs, in-memory state, clone helpers, EF mapping, and the20260414204309_AddRolemasterAutoRetrymigration. - Implement retry-aware Rolemaster roll execution, readable breakdown formatting, and compact log badge/summary output.
- (2026-04-14 20:45Z) Updated the Blazor skill create/edit flows so the automatic retry toggle appears only for Rolemaster open-ended skills and is cleared when the expression stops qualifying.
- Add or update unit, API, persistence, payload-budget, and browser tests that prove the feature end to end.
- Update
README.md, runpwsh ./scripts/ci-local.ps1, and commit the finished implementation.
Surprises & Discoveries
-
Observation: The current Rolemaster engine has only two special open-ended branches: high open-ended chaining and low-end subtraction. There is no second-attempt concept today. Evidence:
RpgRoller/Services/RolemasterRollEngine.cscallsRollHighOpenEndedChainfor high rolls and for low-end subtraction, then immediately formats the final total. -
Observation: Compact log badges are not stored anywhere. They are recalculated when a log page is read, so any retry signal must be derivable from persisted roll data. Evidence:
RpgRoller/Services/GameRollService.cscallsCampaignLogSummaryBuilder.BuildCompactLogEventBadges(...)while buildingCampaignLogListEntry;RollLogEntrydoes not persist badges. -
Observation: The Rolemaster-specific skill UI already hides and normalizes invalid options when the expression is not open-ended percentile, which gives this feature a natural home. Evidence:
RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.csandCharacterPanel.razor.csalready clearFumbleRangewhen the selected Rolemaster expression is not open-ended.
Decision Log
-
Decision: The retry toggle is per skill, not per skill group, and custom rolls do not participate. Rationale: The request explicitly asks for “a toggle for a rolemaster skill.” A skill-group default would widen scope into template inheritance and create unclear behavior for ad hoc custom rolls. Date/Author: 2026-04-04 / Codex
-
Decision: The retry windows are interpreted literally from the request:
77-90 => +5,91-110 => +10. A result of111counts as success and doesn't need to be retried. Rationale: The user gave concrete inclusive and exclusive bounds. Preserving those exact bounds avoids silently changing game rules inside the plan. Date/Author: 2026-04-04 / Codex -
Decision: The retry window is evaluated from the first complete Rolemaster skill result, after the original expression modifier and any low-end subtraction chain are applied, but before any retry bonus is applied. Rationale: The request speaks about “if the result is ...” rather than about a raw trigger die. Using the user-visible result keeps the rule understandable in the UI and in tests. Date/Author: 2026-04-04 / Codex
-
Decision: An eligible roll triggers exactly one automatic retry. The retry does not recurse again even if the retry result also lands in a retry band. Rationale: Recursive retries would make the feature hard to explain, hard to test, and far removed from the user’s “automatic retry” request. Date/Author: 2026-04-04 / Codex
-
Decision: The final stored roll result becomes the retried result plus the retry bonus, while the original first result remains visible in the breakdown and log summary. Rationale: An automatic retry should materially change the outcome, not merely annotate the failed first attempt. Keeping the first attempt visible preserves auditability. Date/Author: 2026-04-04 / Codex
-
Decision: The feature uses “retry” terminology throughout the docs and code, with the persisted Boolean named
RolemasterAutoRetry. Rationale: The user explicitly rejected “skipp” as unclear.RolemasterAutoRetrykeeps the toggle readable in code, API payloads, and UI text. Date/Author: 2026-04-14 / Codex
Outcomes & Retrospective
Milestone 1 is complete. The repo now persists and validates a per-skill RolemasterAutoRetry toggle, exposes it in the skill create/edit UI only for Rolemaster open-ended percentile expressions, and round-trips it through service, API, and persistence tests. Roll execution, breakdown formatting, and log surfacing still need to be implemented before the feature is complete end to end.
Context and Orientation
RpgRoller is an ASP.NET Core plus Blazor Server application. The gameplay state lives in memory as plain domain objects under RpgRoller/Domain, is persisted through EF Core and SQLite under RpgRoller/Data and RpgRoller/Hosting, and is exposed through Minimal API endpoints under RpgRoller/Api. Blazor UI components under RpgRoller/Components render the authenticated workspace and call those APIs.
The Rolemaster roll implementation is isolated well enough that this feature can be added without disturbing D6 or D&D 5e. RpgRoller/Services/RolemasterRollEngine.cs currently handles both ordinary Rolemaster rolls and open-ended percentile rolls. RpgRoller/Services/RollEngine.cs dispatches by ruleset. RpgRoller/Services/GameRollService.cs records rolls, persists them, and builds campaign-log list entries.
Skill configuration flows through several layers. The domain model types are RpgRoller/Domain/GameModels.cs. EF Core maps them in RpgRoller/Data/RpgRollerDbContext.cs. API request and response records live in RpgRoller/Contracts/ApiContracts.cs. The backend service methods that validate and save skill edits live in RpgRoller/Services/GameSkillService.cs and RpgRoller/Services/SkillDefinitionValidator.cs. Blazor form state models live in RpgRoller/Components/Pages/Home.Models.cs. The create and edit skill modal lives in RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor and .razor.cs. Character-panel group editing lives in RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor and .razor.cs.
Campaign log cards are compact list entries, not full detail records. The compact card summary text and badge codes are composed in RpgRoller/Services/CampaignLogSummaryBuilder.cs and rendered in RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor and .razor.cs. Roll detail dice chips are rendered by RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs. If retry metadata needs to survive app restarts, it must either be encoded in the persisted dice JSON or in the persisted breakdown string because RollLogEntry currently stores only Result, Breakdown, and serialized Dice.
Plan of Work
Start by extending the skill model so the retry toggle has a place to live. Add a non-nullable Boolean property named RolemasterAutoRetry to Skill in RpgRoller/Domain/GameModels.cs. Thread that property through the DTO surface in RpgRoller/Contracts/ApiContracts.cs by extending CreateSkillRequest, UpdateSkillRequest, SkillSummary, and CharacterSheetSkill. Keep skill groups unchanged. Update RpgRoller/Services/GameDtoMapper.cs so summaries and character sheets include the new flag. Update cloning or state-copy helpers that copy Skill objects, including RpgRoller/Services/GameStateCloneFactory.cs, so the flag persists through load/save cycles.
Add a database migration for the new column. Update RpgRoller/Data/RpgRollerDbContext.cs so EF Core maps RolemasterAutoRetry as a required Boolean with a default value of false. Generate a migration under RpgRoller/Migrations that adds the column to Skills with a safe default. Do not widen this migration to unrelated schema changes. If existing migration coverage fixtures or history assertions mention the newest migration id, update them so startup migration tests remain accurate.
Once the property exists, tighten validation. Extend RpgRoller/Services/SkillDefinitionValidator.cs so its return value includes the retry flag. Validation must accept RolemasterAutoRetry = true only when the ruleset is Rolemaster and the parsed expression kind is RolemasterOpenEndedPercentile. For every other ruleset or expression kind, the backend must reject true with a specific validation error such as invalid_rolemaster_retry. When the flag is false, behavior must remain unchanged. Update RpgRoller/Services/GameSkillService.cs, RpgRoller/Services/IGameService.cs, and RpgRoller/Api/SkillEndpoints.cs so skill creation and update calls carry the extra argument all the way through.
Implement the retry rule in a dedicated helper instead of burying threshold math inside the roll engine. Add a new backend helper file, for example RpgRoller/Services/RolemasterRetryPolicy.cs, with a small API such as public static int? ResolveSkippRetryBonus(int firstResult). This helper must return 5, 10, or null according to the exact windows described earlier. Put the thresholds here so both tests and the roll engine read the same source of truth.
Then extend the Rolemaster roll engine. Change RpgRoller/Services/RollEngine.cs so Rolemaster rolls can receive the new per-skill toggle. Change RpgRoller/Services/RolemasterRollEngine.cs so open-ended percentile rolls do the following: compute the first attempt exactly as today; evaluate RolemasterRetryPolicy.ResolveSkippRetryBonus(firstResult); if the toggle is off or the policy returns null, return the original result unchanged; otherwise perform one more full roll of the same parsed expression, calculate that second attempt’s result exactly as a normal Rolemaster open-ended roll, add the retry bonus to that second attempt, and store that number as the final roll result.
Make the retry understandable in persisted output. Introduce an optional Attempt property on RollDieResult in RpgRoller/Contracts/ApiContracts.cs so dice from the original roll can be tagged as attempt 1 and retry dice as attempt 2. Keep the existing Sequence property semantics within each attempt. Update RolemasterRollEngine to set Attempt = 1 for the original dice and Attempt = 2 for retry dice. Update RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs so the generated title text mentions “attempt 1” or “retry attempt” when Attempt is present. This keeps the detail view legible without redesigning the dice strip layout.
The breakdown string must become parseable and human-readable. Extend RpgRoller/Services/RollBreakdownFormatter.cs with a formatter dedicated to retry output. Preserve the current breakdown text for each individual attempt, then combine them into a single breakdown string shaped like:
<first-attempt-breakdown>; retry(+5): <retry-attempt-breakdown>; final=<final-result>
or
<first-attempt-breakdown>; retry(+10): <retry-attempt-breakdown>; final=<final-result>
This format is intentionally simple. It is readable in the UI, survives persistence without new tables, and gives CampaignLogSummaryBuilder a stable marker to detect retry badges later. Do not change non-retry breakdown formatting.
Update the compact campaign-log helpers to surface the new special result. RpgRoller/Services/CampaignLogSummaryBuilder.cs should accept the stored breakdown when building badges and compact summaries. Add badge codes rs5 and rs10 for “Retry +5” and “Retry +10”. When a retry marker is present in the breakdown, append a short retry note to the Rolemaster compact summary, for example | retry +5 or | retry +10, while keeping existing rf, r66, and r100 behavior intact. Update RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs so those new badge codes render as visible labels on the log card.
After the backend shape is stable, wire the UI toggle. Extend SkillFormModel in RpgRoller/Components/Pages/Home.Models.cs with RolemasterAutoRetry. Update SkillFormModal.razor.cs so the form copies the value from InitialModel, validates it only for Rolemaster open-ended expressions, and clears it automatically when the skill expression becomes invalid for retry. Update SkillFormModal.razor to show a checkbox only in the Rolemaster open-ended branch, near the fumble-range input, with concise help text that explains the exact windows. Update CharacterPanel.razor.cs so create and edit skill dialogs pass the property in their initial models and request payloads. Do not add a corresponding group-level control.
Expose the setting in the workspace read model so the user can see it again after save. Extend WorkspaceState.SkillDefinitionLabel(...) and RulesetFormHelpers.DescribeRolemasterExpression(...) as needed so a Rolemaster open-ended skill with retry enabled renders a label that includes the retry rule in compact form, for example Open-ended percentile: d100!+15, fumble <= 5, auto retry. Keep the label short enough that existing layout remains intact.
Finally, update documentation. README.md must describe the new Rolemaster skill option, the retry windows, and the fact that the campaign log now shows retry badges. If an example command or screenshot-free narrative is needed, keep it textual and current rather than writing a historical change note.
Milestones
Milestone 1: Persist and validate the skill toggle
At the end of this milestone, the repository can create, read, update, persist, and reload a Rolemaster skill with the retry flag, but no roll behavior changes yet. The new property exists in the domain model, EF Core schema, API contracts, service signatures, DTOs, and Blazor form state. Validation rejects impossible combinations such as D6 plus retry enabled or Rolemaster d10 plus retry enabled.
Run the service and API tests that cover skill validation and persistence from D:\Code\RpgRoller:
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --filter "FullyQualifiedName~ServiceHelperExtractionTests|FullyQualifiedName~ServicePersistenceTests|FullyQualifiedName~WorkspaceQueryServiceTests|FullyQualifiedName~CampaignApiTests"
Acceptance is that new tests prove the flag survives round trips and that stale invalid values are rejected before any roll logic is touched.
Milestone 2: Execute and record automatic retry
At the end of this milestone, an eligible Rolemaster open-ended skill roll automatically performs one retry and stores a final result based on the retry attempt plus the policy bonus. Non-eligible or disabled skills still behave exactly as before. The detailed breakdown string explains the first attempt, the retry bonus, the retry attempt, and the final result. The detail dice strip distinguishes original and retry attempts through titles.
Run targeted Rolemaster service and API tests from D:\Code\RpgRoller:
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --filter "FullyQualifiedName~ServiceRolemasterRollTests|FullyQualifiedName~RolemasterApiTests|FullyQualifiedName~PayloadBudgetTests"
Acceptance is that there are explicit tests for a +5 retry case, a +10 retry case, and a disabled-skill case that proves the old result path remains unchanged.
Milestone 3: Surface the retry in the workspace and lock the behavior
At the end of this milestone, the create and edit skill UI exposes the toggle only when valid, saved skills show the toggle again when reopened, log cards display retry badges, and browser smoke coverage proves the experience without manual clicking. Documentation is updated and the full local parity script passes.
Run from D:\Code\RpgRoller:
pwsh ./scripts/ci-local.ps1
Acceptance is that the run finishes successfully, the new browser test proves the checkbox and badge behavior, and no unrelated payload-budget or smoke-test regressions appear.
Concrete Steps
Work from D:\Code\RpgRoller.
-
Edit the domain model and contracts first so every later compiler error points toward the remaining call sites. Update
RpgRoller/Domain/GameModels.cs,RpgRoller/Contracts/ApiContracts.cs,RpgRoller/Services/GameDtoMapper.cs,RpgRoller/Services/GameStateCloneFactory.cs,RpgRoller/Services/IGameService.cs,RpgRoller/Services/GameSkillService.cs, andRpgRoller/Api/SkillEndpoints.cs. -
Add the EF Core schema update in
RpgRoller/Data/RpgRollerDbContext.cs, generate the migration inRpgRoller/Migrations, and update any migration-history assertions inRpgRoller.Tests/HostingCoverageTests.csif they depend on the latest migration name. -
Add backend validation and retry policy code. Extend
RpgRoller/Services/SkillDefinitionValidator.cs. AddRpgRoller/Services/RolemasterRetryPolicy.cs. UpdateRpgRoller/Services/RollEngine.cs,RpgRoller/Services/RolemasterRollEngine.cs, andRpgRoller/Services/RollBreakdownFormatter.cs. -
Update compact log helpers and UI consumers. Change
RpgRoller/Services/CampaignLogSummaryBuilder.cs,RpgRoller/Services/GameRollService.cs,RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs, andRpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs. -
Update form models and skill forms. Change
RpgRoller/Components/Pages/Home.Models.cs,RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor,RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs,RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs,RpgRoller/Components/Pages/WorkspaceState.cs, andRpgRoller/Components/Pages/HomeControls/RulesetFormHelpers.cs. -
Add or update tests before running the full parity script. The expected primary test files are
RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs,RpgRoller.Tests/Services/ServiceHelperExtractionTests.cs,RpgRoller.Tests/Services/ServicePersistenceTests.cs,RpgRoller.Tests/Services/ServiceRollHelperTests.cs,RpgRoller.Tests/Services/WorkspaceStateTests.cs,RpgRoller.Tests/Api/RolemasterApiTests.cs, andtests/e2e/smoke.spec.js. -
Update
README.md, run the full local parity script, inspectgit status, and commit with a brief message only after all validations pass.
Expected final command transcript:
==> Run tests
Passed! - Failed: 0, Passed: <updated count>, Skipped: 0, Total: <updated count>
==> Enforce coverage thresholds
Line coverage: <at least 90%>
Branch coverage: <at least 70%>
==> Run Playwright smoke test
<smoke tests all pass>
CI checks passed.
Validation and Acceptance
Validation is complete only when all of the following are true.
The backend proves rule correctness. There must be a unit test where the first attempt result is 78 and the stored final result comes from a retry with +5. There must be another where the first attempt result is 96 or another value inside the second band and the stored final result comes from a retry with +10. There must be a test where the skill toggle is disabled and an otherwise eligible first result still does not retry.
The persistence layer proves round-trip safety. A test must create a skill with the toggle enabled, persist the database, reload state, and confirm the skill still exposes RolemasterAutoRetry = true.
The API layer proves contract shape. A test must create and update a Rolemaster open-ended skill through HTTP and confirm the toggle round-trips through SkillSummary, CharacterSheet, and roll results. Invalid combinations must return a concrete API error rather than silently coercing the value.
The compact log proves user-visible behavior. A service or API test must show that a retried roll produces rs5 or rs10 in the log entry badges and that the summary text includes a retry marker. A browser test must create or use a retry-enabled Rolemaster skill, roll it, and verify that the log card shows the retry badge without expanding the detail row first.
The whole repo must still pass the local parity script:
pwsh ./scripts/ci-local.ps1
Idempotence and Recovery
This plan is safe to execute incrementally. Compiler errors after the first contract changes are expected because the repository is strongly typed; fix the next call site rather than backing out partial edits.
If the EF Core migration step is blocked because the development app or tests are holding the SQLite file open, stop the running dotnet process, retry the migration command once, and continue. This repo explicitly allows that recovery step after database changes.
If a partial implementation leaves the feature half-wired, the safe recovery path is to keep the new property and validation in place, set the toggle to default false, and continue forward until tests pass. Do not delete unrelated user changes from the worktree to recover.
Artifacts and Notes
The key code paths to revisit while implementing are these:
RpgRoller/Services/RolemasterRollEngine.cs
RpgRoller/Services/CampaignLogSummaryBuilder.cs
RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs
RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs
The intended retry badge mapping is:
rs5 -> Retry +5
rs10 -> Retry +10
The intended breakdown marker is:
; retry(+5):
; retry(+10):
These marker strings are chosen so they can be detected cheaply without adding another persisted table or JSON blob.
Interfaces and Dependencies
At the end of the implementation, these interfaces and shapes must exist.
In RpgRoller/Domain/GameModels.cs, Skill must expose:
public bool RolemasterAutoRetry { get; set; }
In RpgRoller/Contracts/ApiContracts.cs, these records must include the new Boolean:
public sealed record CreateSkillRequest(..., int? FumbleRange = null, bool RolemasterAutoRetry = false);
public sealed record UpdateSkillRequest(..., int? FumbleRange = null, bool RolemasterAutoRetry = false);
public sealed record SkillSummary(..., int? FumbleRange, bool RolemasterAutoRetry);
public sealed record CharacterSheetSkill(..., int? FumbleRange, bool RolemasterAutoRetry);
RollDieResult must gain an optional attempt marker:
public int? Attempt { get; init; }
In RpgRoller/Services/RolemasterRetryPolicy.cs, define:
public static int? ResolveSkippRetryBonus(int firstResult)
In RpgRoller/Services/SkillDefinitionValidator.cs, the validation result tuple must carry the retry flag so backend callers do not need to re-derive validity from raw request data.
In RpgRoller/Services/RollEngine.cs, the Rolemaster dispatch signature must carry the skill toggle through to RolemasterRollEngine.
In RpgRoller/Services/RolemasterRollEngine.cs, the open-ended roll path must still return:
(int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice)
but it must now build that tuple from one or two attempts depending on the retry policy.
Revision note: created this ExecPlan on 2026-04-04 because the user requested a repository-local execution plan in TASKS.md before implementation.