Add Rolemaster situational modifier execplan

This commit is contained in:
2026-04-14 23:31:07 +02:00
parent 8d59868392
commit 9e91fb2719

277
TASKS.md
View File

@@ -1,245 +1,190 @@
# Rolemaster Automatic Retry
# Rolemaster skill roll situational modifier modal
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.
`PLANS.md` is checked into the repository root. This document must be maintained in accordance with `PLANS.md`.
## 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.
After this change, clicking the dice button for any Rolemaster skill on the play screen will no longer roll immediately. Instead, a modal dialog will open first and ask for a one-time situational modifier for that upcoming roll. The player can leave it blank for zero, enter a positive number such as `20` for a bonus, or enter a negative number such as `-15` for a penalty. Pressing Enter will confirm the roll, pressing Escape will cancel it, and clicking outside the modal will also cancel it.
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 `76` through `90` grant a retry with `+5`; results `91` through `110` grant a retry with `+10`.
The important user-visible rule is that this temporary modifier must be applied everywhere the skill roll logic already uses the skills built-in modifier. For a skill stored as `d100!+50`, entering `20` means the first Rolemaster attempt is evaluated as `roll + 50 + 20`, not as a post-processing adjustment. That means an initial result of `8` becomes `8+50+20=78`, which falls into the existing automatic retry band and therefore triggers the retry flow. The retry attempt must also include the same `+20` situational modifier.
## Progress
- [x] (2026-04-04 23:52Z) Reviewed `PLANS.md` and the current Rolemaster roll, skill-form, API, and log-card code paths.
- [x] (2026-04-04 23:52Z) Authored this ExecPlan in `TASKS.md`.
- [x] (2026-04-14 20:45Z) Added persisted `RolemasterAutoRetry` wiring through the skill model, API contracts, DTOs, in-memory state, clone helpers, EF mapping, and the `20260414204309_AddRolemasterAutoRetry` migration.
- [x] (2026-04-14 21:20Z) Implemented one-shot Rolemaster automatic retry execution, persisted retry-aware breakdown text, attempt-tagged dice detail, and compact `rs5`/`rs10` log badges plus retry summary text.
- [x] (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.
- [x] (2026-04-14 21:46Z) Added unit, API, persistence, payload-budget, and browser coverage for retry validation, retry execution, retry badges, retry detail metadata, and UI toggle behavior.
- [x] (2026-04-14 21:46Z) Updated `README.md`, ran `pwsh ./scripts/ci-local.ps1`, and committed the finished implementation.
- [x] (2026-04-14 21:27:50Z) Created the initial ExecPlan in `TASKS.md`, grounded in the current workspace play flow, API contract, and Rolemaster retry implementation.
- [ ] Add transient situational modifier support to the skill-roll API and service pipeline without persisting anything on `Skill` or in the database schema.
- [ ] Add a Rolemaster-only pre-roll modal on the play screen with autofocus, Escape dismissal, Enter submit, outside-click dismissal, and inline validation for signed integer input.
- [ ] Update Rolemaster roll execution and breakdown formatting so the situational modifier is shown explicitly and participates in retry-band evaluation and retry attempts.
- [ ] Add service, API, and Playwright coverage for the new behavior; update `README.md`; run `jb cleanupcode --build=False ...`; run `pwsh ./scripts/ci-local.ps1`; commit the iteration.
## 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.cs` calls `RollHighOpenEndedChain` for high rolls and for low-end subtraction, then immediately formats the final total.
- Observation: `TASKS.md` was empty before this plan was written, so this ExecPlan now defines the full intended work from scratch.
Evidence: `Get-Item D:\Code\RpgRoller\TASKS.md | Format-List Length` reported `Length : 0`.
- 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.cs` calls `CampaignLogSummaryBuilder.BuildCompactLogEventBadges(...)` while building `CampaignLogListEntry`; `RollLogEntry` does not persist badges.
- Observation: the current skill-roll request only carries visibility, so there is no existing place to send a one-shot situational modifier from the client to the server.
Evidence: `RpgRoller/Contracts/ApiContracts.cs` currently defines `public sealed record RollSkillRequest(string Visibility);`.
- 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.cs` and `CharacterPanel.razor.cs` already clear `FumbleRange` when the selected Rolemaster expression is not open-ended.
- Observation: the current Rolemaster retry rule is already based on the fully computed first attempt total, not just the raw die result, which matches the new requirement once the temporary modifier is included in that total.
Evidence: `RpgRoller/Services/RolemasterRollEngine.cs` resolves retry bands from `firstAttempt.Total`.
- Observation: the repository already uses Blazor modal patterns with overlays and `ElementReference.FocusAsync()` for autofocus, so the new modal can follow an existing local pattern instead of inventing a second approach.
Evidence: `RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor(.cs)` renders a modal and focuses the name input in `OnAfterRenderAsync`.
## 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 situational modifier will be transient request data only and will not be stored on `Skill`, `RollLogEntry`, or in a migration.
Rationale: the feature is explicitly “once for the upcoming roll.” Persisting it would create stale state, require schema work, and misrepresent the feature.
Date/Author: 2026-04-14 / Codex
- Decision: The retry windows are interpreted literally from the request: `76-90 => +5`, `91-110 => +10`. A result of `111` counts 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: `RollSkillRequest` will gain an integer `SituationalModifier` field with a default of `0`, and server-side skill-roll methods will accept the same value.
Rationale: zero is the normal case, avoids null semantics through the stack, and keeps the request payload simple for both tests and UI code.
Date/Author: 2026-04-14 / 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: non-zero situational modifiers will be accepted only for Rolemaster skill rolls. Non-Rolemaster skill rolls will continue to execute immediately without showing the modal, and the server will reject any accidental non-zero modifier sent for another ruleset.
Rationale: the user asked for this behavior only for the Rolemaster system. The server-side guard prevents future UI regressions from silently broadening the feature.
Date/Author: 2026-04-14 / 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 users “automatic retry” request.
Date/Author: 2026-04-04 / Codex
- Decision: the modal input will be stored as raw text in UI state and parsed on confirm rather than bound directly to an `int`.
Rationale: blank must mean zero, signed values must be easy to type, and raw text avoids awkward intermediate states such as a lone `-` while the user is editing.
Date/Author: 2026-04-14 / 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: Rolemaster breakdown strings will show the base skill modifier and the one-shot situational modifier as separate visible terms instead of folding them into one combined number.
Rationale: the user needs to audit why a retry happened. `8+50+20=78` is clearer than `8+70=78`, especially when comparing the stored skill expression with the one-time adjustment.
Date/Author: 2026-04-14 / 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. `RolemasterAutoRetry` keeps the toggle readable in code, API payloads, and UI text.
- Decision: compact log badges do not need a new “situational modifier” badge.
Rationale: the result number already changes, the detailed breakdown will show the exact temporary modifier, and adding a new badge for a one-shot value would create clutter with little value.
Date/Author: 2026-04-14 / Codex
## Outcomes & Retrospective
All three milestones are complete. The repo now persists and validates a per-skill `RolemasterAutoRetry` toggle, executes one automatic retry for eligible Rolemaster open-ended percentile results, records retry-aware breakdown text and attempt-tagged dice detail, surfaces retry summaries plus `rs5` or `rs10` badges in the compact log, and proves the create/edit toggle plus retry badge flow through browser smoke coverage.
No implementation has started yet. The current outcome is a concrete, repository-specific execution plan that resolves the major design choices up front: the modifier is transient, Rolemaster-only, included in retry-band evaluation, and exposed through a dedicated modal in the workspace play flow. The feature will be complete when a novice can follow this plan, roll a Rolemaster skill with `+20`, and observe the same `+20` in both the first attempt and the retry attempt breakdown.
## 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.
This repository is an ASP.NET Core and Blazor application rooted at `D:\Code\RpgRoller`. The user-facing play screen is assembled in `RpgRoller/Components/Pages/Workspace.razor`. That page wires `CharacterPanel` to the coordinator class `RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs`, which currently handles skill rolls by calling `Play.RollSkillAsync`.
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.
The skill list and its dice buttons live in `RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor`. Each button currently emits only the `Guid` skill identifier. `RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor` and `CharacterPanel.razor.cs` forward that identifier upward through the `RollRequested` callback without opening any pre-roll UI.
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`.
The request contract for a skill roll lives in `RpgRoller/Contracts/ApiContracts.cs` as `RollSkillRequest`. The HTTP endpoint is `POST /api/skills/{skillId}/roll` in `RpgRoller/Api/SkillEndpoints.cs`. The service contract is `IGameService.RollSkill(...)` in `RpgRoller/Services/IGameService.cs`, implemented by `RpgRoller/Services/GameService.cs`, and executed by `RpgRoller/Services/GameRollService.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`.
Rolemaster rolling behavior is implemented in `RpgRoller/Services/RollEngine.cs`, `RolemasterRollEngine.cs`, `RolemasterRetryPolicy.cs`, and `RollBreakdownFormatter.cs`. A “situational modifier” in this plan means a temporary integer that is added to or subtracted from the stored skill expression for one roll only. The stored skill expression is still the canonical thing saved on the skill, such as `d100!+50`. The situational modifier exists only in the request that triggers one roll and in the recorded breakdown text that explains that roll afterward.
UI and regression coverage already exist in the repository areas that matter here. `RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs` covers Rolemaster engine behavior. `RpgRoller.Tests/Api/RolemasterApiTests.cs` covers Rolemaster HTTP behavior. `tests/e2e/smoke.spec.js` covers the browser play flow and already contains Rolemaster smoke tests, including the automatic retry badge path. These are the places to extend rather than creating disconnected new test files.
## 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.
Start by widening the skill-roll request path, but keep the feature transient. In `RpgRoller/Contracts/ApiContracts.cs`, change `RollSkillRequest` so it carries both `Visibility` and `SituationalModifier`, defaulting the latter to `0`. Update `RpgRoller/Contracts/RpgRollerJsonSerializerContext.cs` only if the source generator needs to reflect the changed shape. Then thread the new integer through `RpgRoller/Api/SkillEndpoints.cs`, `RpgRoller/Services/IGameService.cs`, `RpgRoller/Services/GameService.cs`, and `RpgRoller/Services/GameRollService.cs`. In `GameRollService.RollSkill`, resolve the campaign and parsed expression exactly as today, then reject a non-zero situational modifier unless the campaign ruleset is Rolemaster. Reuse the existing authorization and visibility checks unchanged. No database or domain model changes are needed for this part.
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.
After the request pipeline can accept the modifier, update the Rolemaster execution path so the temporary value participates in the actual roll math rather than being bolted on afterward. The cleanest repository-local shape is to extend `RollEngine.Roll(...)` and `RolemasterRollEngine.Roll(...)` with an optional `situationalModifier = 0` argument. Only the Rolemaster branch should consume it. In `RolemasterRollEngine`, add the situational modifier to the expression modifier for both standard Rolemaster rolls and open-ended percentile attempts. The first attempt total that feeds `RolemasterRetryPolicy.ResolveAutoRetryBonus(...)` must already include both the stored skill modifier and the situational modifier. The retry attempt must use the same situational modifier again. Update `RollBreakdownFormatter` so Rolemaster text remains explicit, for example `08+50+20=78` for a normal positive path and `(05) -97 +50 +20 = -22` for a low-end path. The retry breakdown must also preserve this explicit style, for example `08+50+20=78; retry(+5): 42+50+20=112; final=117`.
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.
Then add the pre-roll modal to the workspace play flow. Keep the state and orchestration in the existing play coordinator rather than letting `CharacterPanel` call the API itself. Change the roll callback path from `Guid` to `CharacterSheetSkill` so the coordinator has the skill name and expression immediately. Update `RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor(.cs)` and `CharacterPanel.razor(.cs)` to pass the full skill object upward. In `RpgRoller/Components/Pages/WorkspaceState.cs`, add modal state for whether the prompt is open, which skill is pending, the pending raw modifier text, whether the modal is submitting, and the current validation message. In `RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs`, replace the direct-roll entry point with a Rolemaster-aware method that either opens the modal or immediately rolls with modifier `0` for other rulesets. Add companion methods to confirm and cancel the pending Rolemaster roll.
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? ResolveAutoRetryBonus(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.
Render the modal near the bottom of `RpgRoller/Components/Pages/Workspace.razor`, alongside the existing character modals, so it shares the same page-level ownership as other overlays. Create a dedicated component pair at `RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor` and `RolemasterSkillRollModal.razor.cs` instead of expanding `CharacterPanel` further. The modal should show the skill name, the stored expression, a short help line explaining that blank means zero and negative numbers are allowed, and a single signed-number input. The input should autofocus via `ElementReference.FocusAsync()`. Escape should cancel. Enter should submit through the form. Clicking the overlay outside the dialog card should cancel, while clicking inside the card must not bubble out. Use the existing `.modal-overlay` and `.modal-card` styling patterns first; only add CSS in `RpgRoller/wwwroot/styles.css` if the modal needs a small amount of spacing or width tuning.
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.ResolveAutoRetryBonus(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 attempts 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.
Validation belongs in both UI and server code. The modal should trim whitespace and treat an empty field as `0`. If parsing fails, keep the modal open and show an inline error such as “Enter a whole number like 20, -15, or leave blank for 0.” On the server, reject values outside the same Rolemaster modifier limits already enforced by `DiceRules`, namely `-1000` through `1000`. Reuse the existing API error path so invalid requests still surface as user-facing errors without breaking the page.
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.
Finally, update documentation and tests. `README.md` must describe the new Rolemaster roll flow in current-state language, not as a changelog note. Extend service tests to prove that a situational modifier changes Rolemaster totals, triggers retry evaluation from the combined total, and applies again on the retry attempt. Extend API tests to prove the new request payload, server-side Rolemaster-only validation, and breakdown text. Extend Playwright smoke coverage so the browser proves the modal opens only on Rolemaster skill rolls, autofocus works, Enter submits, Escape and outside click dismiss, invalid text stays inline, and a positive situational bonus can be used to cause a retry-enabled result.
## Concrete Steps
Work from `D:\Code\RpgRoller`.
All commands below run from `D:\Code\RpgRoller`.
1. 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`, and `RpgRoller/Api/SkillEndpoints.cs`.
First, implement the request and engine changes, then run targeted tests while the work is still small.
2. Add the EF Core schema update in `RpgRoller/Data/RpgRollerDbContext.cs`, generate the migration in `RpgRoller/Migrations`, and update any migration-history assertions in `RpgRoller.Tests/HostingCoverageTests.cs` if they depend on the latest migration name.
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --filter "FullyQualifiedName~ServiceRolemasterRollTests|FullyQualifiedName~RolemasterApiTests"
3. Add backend validation and retry policy code. Extend `RpgRoller/Services/SkillDefinitionValidator.cs`. Add `RpgRoller/Services/RolemasterRetryPolicy.cs`. Update `RpgRoller/Services/RollEngine.cs`, `RpgRoller/Services/RolemasterRollEngine.cs`, and `RpgRoller/Services/RollBreakdownFormatter.cs`.
After the UI modal is in place, run the browser smoke suite directly.
4. Update compact log helpers and UI consumers. Change `RpgRoller/Services/CampaignLogSummaryBuilder.cs`, `RpgRoller/Services/GameRollService.cs`, `RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs`, and `RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs`.
pwsh ./scripts/run-playwright.ps1
5. 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`, and `RpgRoller/Components/Pages/HomeControls/RulesetFormHelpers.cs`.
Before closing the iteration, format every touched file using the repository rule. Replace the placeholder list with the exact touched file paths separated by semicolons.
6. 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`, and `tests/e2e/smoke.spec.js`.
jb cleanupcode --build=False RpgRoller/Contracts/ApiContracts.cs;RpgRoller/Api/SkillEndpoints.cs;RpgRoller/Services/IGameService.cs;RpgRoller/Services/GameService.cs;RpgRoller/Services/GameRollService.cs;RpgRoller/Services/RollEngine.cs;RpgRoller/Services/RolemasterRollEngine.cs;RpgRoller/Services/RollBreakdownFormatter.cs;RpgRoller/Components/Pages/Workspace.razor;RpgRoller/Components/Pages/WorkspaceState.cs;RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs;RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor;RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs;RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor;RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor.cs;RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor;RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor.cs;RpgRoller/wwwroot/styles.css;RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs;RpgRoller.Tests/Api/RolemasterApiTests.cs;tests/e2e/smoke.spec.js;README.md
7. Update `README.md`, run the full local parity script, inspect `git 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:
Run the repository-wide local CI script as the final proof.
pwsh ./scripts/ci-local.ps1
Then create one brief commit for the iteration.
git add TASKS.md README.md RpgRoller/Contracts/ApiContracts.cs RpgRoller/Api/SkillEndpoints.cs RpgRoller/Services/IGameService.cs RpgRoller/Services/GameService.cs RpgRoller/Services/GameRollService.cs RpgRoller/Services/RollEngine.cs RpgRoller/Services/RolemasterRollEngine.cs RpgRoller/Services/RollBreakdownFormatter.cs RpgRoller/Components/Pages/Workspace.razor RpgRoller/Components/Pages/WorkspaceState.cs RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor.cs RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor.cs RpgRoller/wwwroot/styles.css RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs RpgRoller.Tests/Api/RolemasterApiTests.cs tests/e2e/smoke.spec.js
git commit -m "Add Rolemaster situational roll modifier prompt"
Expected proof points during implementation are:
A Rolemaster skill such as d100!+50 opens the modal instead of rolling immediately.
Leaving the field blank and pressing Enter records a normal roll.
Typing 20 and pressing Enter records a breakdown that visibly contains +20.
If the first attempt becomes 76 through 110 after adding the situational modifier, the existing retry flow still fires.
A D6 or D&D 5e skill still rolls immediately with no popup.
## Validation and Acceptance
Acceptance is behavioral, not just “the code compiles.”
Start the browser smoke environment with `pwsh ./scripts/run-playwright.ps1` or run the application locally and navigate to the play screen. Create or seed a Rolemaster campaign, a character, and an open-ended skill such as `Observation` with `d100!+50`, `fumbleRange: 5`, and `rolemasterAutoRetry: true`.
On the play screen, clicking `Roll Observation` must open a modal dialog. The modifier input must receive focus immediately. Pressing Escape must close the dialog with no roll recorded. Clicking the backdrop outside the dialog must also close it with no roll recorded. Reopening the dialog and pressing Enter with the field blank must submit a normal zero-modifier roll.
Reopen the dialog and enter `20`. After confirming, the recorded roll detail must show the temporary modifier as a separate term in the breakdown. If the first computed attempt lands in the retry window, the breakdown must show the same `+20` in both attempts and the final total must reflect the retry bonus on top of the retry attempt. The compact log entry should still show the existing retry badge behavior, and expanding the detail should show the existing attempt markers on the dice chips.
Run `dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj` and expect the suite to pass. The new or updated tests should fail before the feature is implemented and pass after it is complete. Run `pwsh ./scripts/ci-local.ps1` and expect the build, tests, coverage gate, and Playwright smoke test to pass end-to-end.
## 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.
This feature should be implemented additively and is safe to retry. Re-running the same code-edit steps should only replace the intended current-state logic. No migration is expected for this feature because the modifier is transient. If a draft implementation accidentally introduces persistence for the modifier, remove that persistence before considering the work complete.
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.
If the UI modal gets into a bad state during development, the safe recovery path is to clear only the workspace prompt state in `WorkspaceState` and retry the interaction. If Playwright fails because the temporary application process is still running, stop the lingering `dotnet` process once, rerun the script, and confirm the health endpoint responds before rechecking the smoke suite.
## Artifacts and Notes
The key code paths to revisit while implementing are these:
The most important visible transcript to preserve during implementation is the breakdown text for a retry-causing situational bonus. A representative successful result should look like this shape, with different random numbers allowed:
RpgRoller/Services/RolemasterRollEngine.cs
RpgRoller/Services/CampaignLogSummaryBuilder.cs
RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs
RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs
08+50+20=78; retry(+5): 42+50+20=112; final=117
The intended retry badge mapping is:
The corresponding browser-level proof should include a log row that shows the final result, retains the existing retry badge, and expands into detail whose dice titles still include attempt markers such as:
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.
Roll 8, step 1, attempt 1, Rolemaster open-ended initial
Roll 42, step 1, retry attempt 2, Rolemaster open-ended initial
## Interfaces and Dependencies
At the end of the implementation, these interfaces and shapes must exist.
At the end of the implementation, these repository interfaces should exist in the following shapes.
In `RpgRoller/Domain/GameModels.cs`, `Skill` must expose:
In `RpgRoller/Contracts/ApiContracts.cs`, define:
public bool RolemasterAutoRetry { get; set; }
public sealed record RollSkillRequest(string Visibility, int SituationalModifier = 0);
In `RpgRoller/Contracts/ApiContracts.cs`, these records must include the new Boolean:
In `RpgRoller/Services/IGameService.cs`, define:
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);
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier = 0);
`RollDieResult` must gain an optional attempt marker:
In `RpgRoller/Services/GameRollService.cs`, define a matching method:
public int? Attempt { get; init; }
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier)
In `RpgRoller/Services/RolemasterRetryPolicy.cs`, define:
In `RpgRoller/Services/RollEngine.cs`, extend the Rolemaster path with:
public static int? ResolveAutoRetryBonus(int firstResult)
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(
RulesetKind ruleset,
DiceExpression expression,
int wildDice,
bool allowFumble,
int? fumbleRange,
bool rolemasterAutoRetry = false,
int situationalModifier = 0)
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/Components/Pages/HomeControls/SkillGroupBlock.razor.cs` and `CharacterPanel.razor.cs`, change the roll callback shape to carry the full skill:
In `RpgRoller/Services/RollEngine.cs`, the Rolemaster dispatch signature must carry the skill toggle through to `RolemasterRollEngine`.
[Parameter]
public EventCallback<CharacterSheetSkill> RollSkillRequested { get; set; }
In `RpgRoller/Services/RolemasterRollEngine.cs`, the open-ended roll path must still return:
[Parameter]
public EventCallback<CharacterSheetSkill> RollRequested { get; set; }
(int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice)
Create a new modal component at `RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor` and `.razor.cs` that accepts at least the visibility flag, skill label, expression label, raw modifier text, submit state, and confirm/cancel callbacks. The component should use only existing Blazor and repository dependencies; no third-party modal library is required or desired.
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.
Plan revision note (2026-04-14 / Codex): created the initial ExecPlan for the new Rolemaster one-shot situational modifier modal because `TASKS.md` was empty and the feature needs a self-contained implementation guide before coding begins.