Files
RpgRoller/TASKS.md

27 KiB

Finish GameService and Workspace Decomposition

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 repository root. This document must be maintained in accordance with PLANS.md.

Purpose / Big Picture

This work finishes a refactor that is already partly complete in the current tree. After the remaining changes are done, the application should behave exactly as it does now for login, campaign management, character management, rolling, live updates, and admin actions, but the code should be organized around small, obvious ownership boundaries instead of two oversized coordinators.

The user-visible proof is intentionally boring: after starting the app, logging in, selecting a campaign, rolling skills, opening the admin screen, and letting live updates reconnect, everything should still work the same. The win is for future implementation work. A novice contributor should be able to open a focused file such as RpgRoller/Services/GameSkillService.cs or RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs, make one change, and not risk unrelated behavior.

Progress

  • (2026-04-04 22:46Z) Re-read AGENTS.md and PLANS.md, then converted this file from a blueprint into a self-contained ExecPlan.
  • (2026-04-04 22:46Z) Inspected the current backend state. RpgRoller/Services/GameService.cs, GameStateStore.cs, GamePersistenceService.cs, GameAuthService.cs, GameCampaignService.cs, GameCharacterService.cs, GameSkillService.cs, GameRollService.cs, and GameUserAdministrationService.cs already exist.
  • (2026-04-04 22:46Z) Inspected the current frontend state. RpgRoller/Components/Pages/WorkspaceState.cs, WorkspaceSessionCoordinator.cs, WorkspaceCampaignCoordinator.cs, WorkspaceCampaignScopeCoordinator.cs, WorkspacePlayCoordinator.cs, WorkspaceAdminCoordinator.cs, WorkspaceLiveStateController.cs, WorkspaceFeedbackService.cs, and WorkspaceToast.cs already exist.
  • (2026-04-04 22:46Z) Marked the large structural extractions as already done in this plan instead of treating the repository as pre-refactor.
  • (2026-04-04 23:03Z) Completed backend shared-helper consolidation. GameStateStore now owns campaign-state version mutations, GameAuthorization, GameContextResolver, and GameDtoMapper now own the shared helper seams, and the domain services delegate to them instead of keeping private copies.
  • Complete backend roll decomposition. Remaining work: extract the dice engines, breakdown formatting, and compact log summary logic out of RpgRoller/Services/GameRollService.cs while preserving all existing roll behavior.
  • (2026-04-04 23:03Z) Finished thinning RpgRoller/Services/GameService.cs for startup and campaign-state bootstrap. The constructor now loads persistence and rebuilds campaign-state versions through GameStateStore without keeping private helper methods.
  • Finish thinning RpgRoller/Components/Pages/Workspace.razor.cs. Remaining work: remove the large mirror of WorkspaceState properties and the excess pass-through wrappers so the file acts as a composition root plus lifecycle and JS-invokable bridge.
  • Update README.md and this ExecPlan after the remaining code changes land so the documentation reflects the final, not intermediate, structure. Completed in this iteration: backend helper descriptions and current remaining scope.

Surprises & Discoveries

  • Observation: backend helper consolidation was lower risk than it first looked because most duplicated code already matched line-for-line semantics. Evidence: after moving authorization, session resolution, and mapping into GameAuthorization, GameContextResolver, and GameDtoMapper, the surrounding service tests passed without behavioral updates.

  • Observation: GameRollService remained the only backend file with broad mixed ownership after the shared helpers moved out. Evidence: the service now delegates authorization, context, state-snapshot mapping, and roll/log DTO mapping, but it still contains dice algorithms, compact log summary formatting, event badge generation, and dice serialization in one file.

  • Observation: GameRollService is now the main backend monolith. Evidence: RpgRoller/Services/GameRollService.cs still owns D6 logic, Rolemaster logic, log summary formatting, event badge generation, and JSON dice serialization in one file.

  • Observation: the frontend refactor introduced one extra collaborator that was not named in the original blueprint, and that collaborator is worth keeping. Evidence: RpgRoller/Components/Pages/WorkspaceCampaignScopeCoordinator.cs now owns selected-campaign reload, selected-character synchronization, log reset, and unauthorized-session handling. Those behaviors are cohesive and should not be pushed back into Workspace.razor.cs.

  • Observation: Workspace.razor.cs is structurally better than before but still too wide because it mirrors state into local aliases. Evidence: RpgRoller/Components/Pages/Workspace.razor.cs contains a large block of properties such as private UserSummary? User { get => State.User; set => State.User = value; } and many one-line delegates such as private Task RollSkillAsync(Guid skillId) => Play.RollSkillAsync(skillId);.

  • Observation: README.md already describes the repository as partially refactored. Evidence: the current README.md names the extracted service and coordinator files directly, so the final implementation must update that description in place rather than add a historical change log.

Decision Log

  • Decision: Treat the repository as partially completed work, not as a blank implementation target. Rationale: the current tree already contains the major backend and frontend file extractions, and an accurate ExecPlan must let a novice resume from the current state without redoing finished work. Date/Author: 2026-04-04 / Codex

  • Decision: Keep RpgRoller/Components/Pages/WorkspaceCampaignScopeCoordinator.cs as a first-class collaborator in the plan. Rationale: it already owns coherent campaign-scope behavior and reduces the chance that unauthorized-session handling and selection refresh logic drift back into Workspace.razor.cs. Date/Author: 2026-04-04 / Codex

  • Decision: Use the remaining work to consolidate shared backend helpers instead of creating a second round of large, nearly duplicated services. Rationale: most of the benefit of the first extraction is already present. The missing value now is shared helper ownership, not more top-level service files. Date/Author: 2026-04-04 / Codex

  • Decision: Keep the new backend helper seams static for now instead of introducing injected utility services. Rationale: the extracted logic is pure over GameStateStore plus method inputs, so static helpers keep wiring simple while still removing the duplication that was obscuring the domain services. Date/Author: 2026-04-04 / Codex

  • Decision: Keep validation instructions in this ExecPlan even though this revision is documentation-only. Rationale: PLANS.md requires executable validation guidance, but the user explicitly requested no CI or test work for this pass. The commands remain here for the implementation pass that follows later. Date/Author: 2026-04-04 / Codex

Outcomes & Retrospective

The repository now has the shared backend seams that the earlier rewrite described as missing. GameStateStore owns campaign-state version mutation, GameAuthorization owns shared access checks, GameContextResolver owns session and campaign resolution, and GameDtoMapper owns the backend read-model construction that had been repeated across services.

The remaining work is narrower than before. The repository now needs second-wave cleanup focused mainly on GameRollService algorithm extraction and the final Workspace binding cleanup. GameService is already at the intended facade shape, so later iterations can stay smaller and more reviewable.

Context and Orientation

This repository is an ASP.NET Core and Blazor Server application. The backend gameplay API is centered on RpgRoller/Services/IGameService.cs and its concrete implementation RpgRoller/Services/GameService.cs. The frontend authenticated workspace is centered on RpgRoller/Components/Pages/Workspace.razor and RpgRoller/Components/Pages/Workspace.razor.cs.

A "thin facade" in this plan means a class that mainly wires collaborators and forwards calls. It does not own substantial business logic. A "composition root" means the place where collaborators are constructed and connected together. In this repository, GameService and Workspace.razor.cs should be composition roots. A "campaign-state tracker" means the in-memory version counters used to decide whether a roster, character sheet, or log changed. Those counters currently live in RpgRoller/Services/GameStateStore.cs as GameCampaignStateTracker.

The current backend state is better than the old monolith. RpgRoller/Services/GameService.cs already delegates its public methods to GameAuthService, GameCampaignService, GameCharacterService, GameSkillService, GameRollService, and GameUserAdministrationService. RpgRoller/Services/GamePersistenceService.cs already owns SQLite loading and saving. RpgRoller/Services/SkillDefinitionValidator.cs, RoleSerializer.cs, RollVisibilityParser.cs, CustomRollOptionsResolver.cs, and GameStateCloneFactory.cs already exist as helper files.

The backend shared-helper duplication is now resolved. RpgRoller/Services/GameStateStore.cs owns campaign-state version mutations. RpgRoller/Services/GameAuthorization.cs owns shared access checks. RpgRoller/Services/GameContextResolver.cs owns session-token and campaign resolution. RpgRoller/Services/GameDtoMapper.cs owns the backend read models returned by the services. The main backend target that remains is RpgRoller/Services/GameRollService.cs, which still combines dice algorithms, compact log formatting, event badges, and dice serialization in one file.

The current frontend state is also better than the old monolith. RpgRoller/Components/Pages/WorkspaceState.cs holds most UI state and many computed projections. Session/bootstrap behavior lives in WorkspaceSessionCoordinator.cs. Campaign management and modal flows live in WorkspaceCampaignCoordinator.cs. Selected campaign scope refresh lives in WorkspaceCampaignScopeCoordinator.cs. Play/log behavior lives in WorkspacePlayCoordinator.cs. Admin behavior lives in WorkspaceAdminCoordinator.cs. Live event reconciliation lives in WorkspaceLiveStateController.cs. Toast and announcement behavior lives in WorkspaceFeedbackService.cs.

The remaining frontend problem is that RpgRoller/Components/Pages/Workspace.razor.cs still mirrors a large amount of WorkspaceState into local alias properties and exposes many single-line wrapper methods only because the Razor file has not been fully retargeted to the composed surface. The next pass should delete those mirrors rather than add more wrappers.

Plan of Work

Milestone 1: Consolidate backend shared state and helper ownership

Begin by removing duplication that is now spread across the backend service files. Extend RpgRoller/Services/GameStateStore.cs so it owns the campaign-state tracker operations that are currently repeated in several places. Specifically, move GetOrCreateCampaignStateLocked, AddCharacterStateLocked, RemoveCharacterStateLocked, TouchRosterLocked, TouchCharacterLocked, TouchLogLocked, and RebuildCampaignStateLocked into GameStateStore. Once those methods exist there, delete the repeated private copies from GameService, GameCharacterService, GameSkillService, GameRollService, and GameUserAdministrationService.

In parallel, create RpgRoller/Services/GameAuthorization.cs, RpgRoller/Services/GameContextResolver.cs, and RpgRoller/Services/GameDtoMapper.cs. Keep them as static helper classes unless an implementation detail forces constructor injection. GameAuthorization should own role checks and "can view campaign", "can edit character", and "can view roll" decisions. GameContextResolver should own session-token-to-user lookup, campaign-context lookup, and character-to-campaign resolution. GameDtoMapper should own summary and response mapping such as user summaries, campaign summaries, campaign rosters, character summaries, character sheets, roll results, and campaign-state snapshots. Update each backend domain service to call these helpers instead of carrying its own copies. The end of this milestone should leave each domain service focused on one workflow family instead of common glue code.

Milestone 2: Break up the roll engine and compact log formatting

After the shared helpers are in place, split RpgRoller/Services/GameRollService.cs into a smaller orchestration file plus algorithmic helpers. Create RpgRoller/Services/RollEngine.cs as the dispatcher for ruleset-specific rolling, and create StandardRollEngine.cs, D6RollEngine.cs, and RolemasterRollEngine.cs for the actual dice logic. Move human-readable breakdown formatting into RpgRoller/Services/RollBreakdownFormatter.cs. Move compact log summary text, badge generation, and custom-roll expression extraction into RpgRoller/Services/CampaignLogSummaryBuilder.cs.

Keep GameRollService responsible for the public workflow: authorize the request, look up the skill or character, ask the roll engine to compute the dice, record the log entry, and return the mapped response. Do not change JSON contracts, roll totals, badge text, breakdown text, or log paging behavior. The easiest way to stay safe is to move logic in place first, preserve existing method names where possible, and only then simplify call sites.

Milestone 3: Finish thinning GameService

Once state-version helpers and backend shared helpers are centralized, return to RpgRoller/Services/GameService.cs. Remove every remaining private helper there. The constructor should create the shared collaborators, call persistence bootstrap, and stop. Startup loading should happen through GamePersistenceService.LoadStateFromDatabase() plus GameStateStore.RebuildCampaignStateLocked() called from one place only. GameService should retain public IGameService methods and nothing more than lightweight delegation and ruleset enumeration.

If constructor readability suffers, create small private factory methods inside GameService only for collaborator construction. Do not reintroduce domain behavior there. The acceptance standard for this milestone is that a reader can scan the entire file quickly and understand all wiring without scrolling through business logic.

Milestone 4: Finish thinning Workspace and clean up Razor bindings

With the backend stable, finish the frontend cleanup. Keep RpgRoller/Components/Pages/WorkspaceCampaignScopeCoordinator.cs as the owner of selected-campaign scope refresh. In RpgRoller/Components/Pages/Workspace.razor.cs, delete the mirror block that copies WorkspaceState into local aliases such as User, Campaigns, SelectedCampaign, and the log-detail dictionaries. Keep State public to the Razor file and bind directly through State wherever the values are plain data or pure computed projections.

To support direct binding, move the remaining pure presentation helpers into RpgRoller/Components/Pages/WorkspaceState.cs. That includes owner labels and skill-definition labels, because those are pure projections over the selected campaign, selected user, and skill data. Keep imperative flows in the coordinators. The file Workspace.razor.cs should still own dependency injection, collaborator construction, lifecycle methods, JSInvokable entry points, DisposeAsync, and any tiny wrappers needed to satisfy component callback signatures. It should stop looking like a second state bag.

Then update RpgRoller/Components/Pages/Workspace.razor so it uses the composed surface consistently. Data should come from State, management actions from Campaigns, play actions from Play, admin actions from Admin, and session actions from Session. Scope may remain an internal collaborator if it simplifies orchestration and keeps the markup readable. Avoid creating deep object chains; clear names matter more than ideological purity.

Milestone 5: Final documentation alignment

After the code refactor is complete, update README.md so its code-organization section describes the final structure plainly and completely. Update this ExecPlan in place. Mark completed progress items with timestamps, add any implementation surprises, record final decisions, and write a closing retrospective. Do not add a historical mini-changelog. The documentation should describe the repository as it exists after the refactor lands.

Concrete Steps

Work from D:\Code\RpgRoller.

Start every future implementation pass by re-reading the plan and checking the current tree:

Get-Content PLANS.md
Get-Content TASKS.md
git status --short
rg --files RpgRoller/Services RpgRoller/Components/Pages

Shared backend helper consolidation is complete in the current tree. The next backend pass should begin by inspecting the remaining roll-service concentration before editing:

Get-Content RpgRoller\Services\GameService.cs
Get-Content RpgRoller\Services\GameRollService.cs
Get-Content RpgRoller\Services\GameAuthorization.cs
Get-Content RpgRoller\Services\GameContextResolver.cs
Get-Content RpgRoller\Services\GameDtoMapper.cs
Get-Content RpgRoller\Services\GameStateStore.cs

Keep the next extraction small. Move one cohesive cluster at a time out of GameRollService so tests can prove that dice totals, breakdown strings, and compact log responses stayed unchanged.

When beginning frontend cleanup, inspect the current composition surface before editing:

Get-Content RpgRoller\Components\Pages\Workspace.razor.cs
Get-Content RpgRoller\Components\Pages\Workspace.razor
Get-Content RpgRoller\Components\Pages\WorkspaceState.cs
Get-Content RpgRoller\Components\Pages\WorkspaceCampaignScopeCoordinator.cs
Get-Content RpgRoller\Components\Pages\WorkspacePlayCoordinator.cs

Move pure projections into WorkspaceState, simplify the composition root, and then retarget the Razor file to the composed surface. Keep the child components in RpgRoller/Components/Pages/HomeControls/ stable unless a binding signature must change to support the cleanup.

When code work resumes later, validate after each meaningful iteration with the repo-standard commands:

pwsh ./scripts/ci-local.ps1
pwsh ./scripts/run-playwright.ps1

The expected result is simple: no failing tests, no coverage regression, and the Playwright smoke flow completes successfully against a temporary database.

Validation and Acceptance

This implementation revision ran targeted helper tests during extraction. Full repo validation through pwsh ./scripts/ci-local.ps1 remains mandatory before considering the iteration complete.

The backend is accepted when RpgRoller/Services/GameService.cs contains only collaborator wiring, ruleset enumeration, and public delegation; when shared authorization, context, mapping, and campaign-state helper logic each live in one place; and when RpgRoller/Services/GameRollService.cs no longer embeds the dice engines or compact log summary builders.

The frontend is accepted when RpgRoller/Components/Pages/Workspace.razor.cs reads like a composition root instead of a state mirror, and when RpgRoller/Components/Pages/Workspace.razor binds primarily through State, Session, Campaigns, Play, and Admin.

The user-visible acceptance flow is:

Log in through the home page, land in the authenticated workspace, switch between Play and Campaign Management, create or edit a character, roll a skill, expand a roll detail row, and open the Admin screen as an admin user. Confirm that live connection status still updates, the selected campaign still persists, and newly recorded rolls still appear and auto-expand as before.

The repo-level acceptance flow is:

Run pwsh ./scripts/ci-local.ps1 from D:\Code\RpgRoller and expect the script to finish without failed tests or coverage failures. After any frontend-affecting iteration, run pwsh ./scripts/run-playwright.ps1 and expect the smoke test to complete successfully.

Idempotence and Recovery

This refactor should stay additive and repeatable. Re-reading the repo and re-running the inspection commands is always safe. Re-applying small helper extractions is safe as long as the public contracts remain unchanged.

No database schema change is planned here. If implementation work needs a running app for manual verification, prefer using a temporary SQLite database through ConnectionStrings__RpgRoller rather than reusing a valuable local database file. If a partial edit leaves the repository uncompilable, fix forward by finishing the helper extraction or by restoring the affected file to the last committed content through a normal edit, not through destructive git reset commands.

Keep commits small. If the work splits into multiple commits, pause after each commit and update the Progress section before continuing, so the plan remains restartable from the current tree.

Artifacts and Notes

The current tree already shows the intended direction. These excerpts are the key evidence that the refactor is partway done but not finished:

public sealed class GameService : IGameService
{
    public GameService(...)
    {
        m_StateStore = new();
        m_PersistenceService = new(dbContextFactory, m_StateStore);
        m_AuthService = new(m_StateStore, passwordHasher, m_PersistenceService);
        ...
        LoadStateFromDatabase();
    }
}

That excerpt shows the facade wiring is already present, but the startup helper still sits in GameService.

private UserSummary? User { get => State.User; set => State.User = value; }
private Task RollSkillAsync(Guid skillId) => Play.RollSkillAsync(skillId);

Those excerpts from Workspace.razor.cs show the remaining mirror and pass-through pattern that should be deleted in the final cleanup.

public sealed class WorkspaceCampaignScopeCoordinator
{
    public async Task RefreshCampaignScopeAsync()
    {
        ...
        await RefreshCampaignRosterAsync();
        await m_RefreshSelectedCharacterSheetAsync();
        await m_RefreshCampaignLogAsync(null);
        ...
    }
}

That excerpt shows the frontend already has a real collaborator worth preserving rather than folding back into Workspace.

Interfaces and Dependencies

The implementation should end with the following stable helper surfaces.

In RpgRoller/Services/GameStateStore.cs, define instance methods that own campaign-state tracker mutation:

public GameCampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId)
public void RebuildCampaignStateLocked()
public void AddCharacterStateLocked(Guid? campaignId, Guid characterId)
public void RemoveCharacterStateLocked(Guid? campaignId, Guid characterId)
public void TouchRosterLocked(Guid? campaignId)
public void TouchCharacterLocked(Guid? campaignId, Guid characterId)
public void TouchLogLocked(Guid? campaignId)

In RpgRoller/Services/GameAuthorization.cs, define static helpers for shared access rules:

public static bool HasRole(UserAccount user, string role)
public static bool CanViewCampaign(GameStateStore stateStore, Guid actorUserId, Guid campaignId)
public static bool CanEditCharacter(Guid actorUserId, Character character, Campaign campaign)
public static bool CanViewRoll(Guid actorUserId, Campaign campaign, RollLogEntry entry)

In RpgRoller/Services/GameContextResolver.cs, define static helpers for shared lookups:

public static UserAccount? ResolveUserLocked(GameStateStore stateStore, string sessionToken)
public static ServiceResult<(UserAccount User, Campaign Campaign)> ResolveCampaignContextLocked(GameStateStore stateStore, string sessionToken, Guid campaignId)
public static bool TryResolveCharacterCampaignLocked(GameStateStore stateStore, Character character, out Campaign campaign, out ServiceError? error)

In RpgRoller/Services/GameDtoMapper.cs, define static mapping helpers for the backend read models returned by the services. At minimum, this file must own user summaries, admin user summaries, campaign options, campaign summaries, campaign rosters, character summaries, character sheets, roll results, campaign log entries, campaign log list entries, and campaign-state snapshots.

In RpgRoller/Services/RollEngine.cs, define a small dispatcher class with one public method that receives a RulesetKind, a parsed DiceExpression, the D6 wild-die settings, and the optional Rolemaster fumble range, then returns (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice). The ruleset-specific implementations should live in StandardRollEngine.cs, D6RollEngine.cs, and RolemasterRollEngine.cs.

In RpgRoller/Services/RollBreakdownFormatter.cs, keep only pure string-formatting helpers. In RpgRoller/Services/CampaignLogSummaryBuilder.cs, keep only pure compact-log helpers such as summary text, event badges, and custom-roll expression extraction.

In RpgRoller/Components/Pages/Workspace.razor.cs, the final composed surface should be limited to:

private WorkspaceState State { get; }
private WorkspaceSessionCoordinator Session { get; }
private WorkspaceCampaignCoordinator Campaigns { get; }
private WorkspaceCampaignScopeCoordinator Scope { get; }
private WorkspacePlayCoordinator Play { get; }
private WorkspaceAdminCoordinator Admin { get; }
private WorkspaceLiveStateController Live { get; }
private WorkspaceFeedbackService Feedback { get; }

The component may keep tiny wrapper methods for lifecycle, JSInvokable entry points, disposal, and menu callbacks, but it should not keep a second copy of the state model.

In RpgRoller/Components/Pages/WorkspaceState.cs, keep all plain state plus pure computed and formatting helpers needed directly by the Razor file. That includes selected campaign name, selected play character projections, screen flags, connection-state label and CSS class, app CSS class, owner labels, and skill-definition labels.

Revision note (2026-04-04): Replaced the old blueprint with an ExecPlan, reconciled it against the code already present in the repository, and marked completed versus remaining refactor work after direct file inspection. The reason for this rewrite is that AGENTS.md now requires complex refactors to be tracked as ExecPlans maintained under PLANS.md.

Revision note (2026-04-04 23:03Z): Marked backend shared-helper consolidation and GameService facade thinning as complete after implementing GameAuthorization, GameContextResolver, GameDtoMapper, and GameStateStore tracker methods. Updated the remaining scope so the next pass starts with GameRollService decomposition and later Workspace cleanup.