20 Commits

Author SHA1 Message Date
9581442cab Remove completed task tracker 2026-04-03 02:35:46 +02:00
7d91e7c900 Pad Rolemaster die chip values 2026-04-03 01:54:04 +02:00
923c6ae26d Remove redundant Rolemaster parser states 2026-04-03 01:44:21 +02:00
f0dd79e589 Generalize Rolemaster standard dice parsing 2026-04-03 01:33:32 +02:00
e5f00fa693 Add Rolemaster payload budget coverage 2026-04-03 01:15:09 +02:00
61ea310179 Add ruleset-aware Rolemaster editors 2026-04-03 01:11:10 +02:00
960197354a Fix Rolemaster low-end open roll math 2026-04-03 00:58:07 +02:00
0059fde74f Implement Rolemaster roll execution 2026-04-03 00:51:36 +02:00
9b9927084b Add repo Playwright smoke setup 2026-04-03 00:39:42 +02:00
48439fd21d Persist Rolemaster fumble range 2026-04-03 00:32:17 +02:00
90afe3b06b Add Rolemaster ruleset parsing scaffolding 2026-04-03 00:15:02 +02:00
13c6215c89 Clarify migration acceptance criteria 2026-04-03 00:08:54 +02:00
da9dc24d8e Updated AGENTS.md 2026-04-03 00:08:44 +02:00
f750f5adc4 updated AGENTS.md 2026-04-02 23:59:33 +02:00
31dcb0c4a9 Updated rolemaster tasks 2026-04-02 01:44:07 +02:00
ac586b0e55 Refine Rolemaster support plan 2026-04-02 01:35:26 +02:00
fadb7efd64 Add Rolemaster support plan 2026-04-02 01:13:25 +02:00
bf6113f790 cleared tasks 2026-04-02 01:04:08 +02:00
0ac1bda10b Fingerprint stylesheet asset 2026-04-02 00:56:48 +02:00
f04f4aa08a Updated agents 2026-04-02 00:52:27 +02:00
45 changed files with 2042 additions and 384 deletions

2
.gitignore vendored
View File

@@ -11,6 +11,8 @@ artifacts/
!.vscode/launch.json !.vscode/launch.json
!.vscode/tasks.json !.vscode/tasks.json
node_modules/ node_modules/
playwright-report/
test-results/
# User secrets / configs # User secrets / configs
appsettings.Development.json appsettings.Development.json

View File

@@ -8,12 +8,14 @@ Also see the other related technical documentation: README.md.
- PowerShell doesn't support bash-style heredocs. If complex scripts need to be executed, consider using python. Run Python code using python -c with inline commands instead of python - <<'PY'. - PowerShell doesn't support bash-style heredocs. If complex scripts need to be executed, consider using python. Run Python code using python -c with inline commands instead of python - <<'PY'.
- web.config in the server is different than locally, it must be exluded from deployment. - web.config in the server is different than locally, it must be exluded from deployment.
- Before beginning with the edit phase, always present a plan first. Only begin editing after the user approves the plan. - Before beginning with the edit phase, always present a plan first. Only begin editing after the user approves the plan.
- Don't make assumptions in the plan. If necessary, ask all clarifying questions before presenting the final plan.
- After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary. - After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary.
- After every iteration, run "scripts/ci-local.ps1" and ensure that nothing broke. - After every iteration, run "scripts/ci-local.ps1" and ensure that nothing broke.
- After every iteration, update all related documentation according to the change, and evaluate if a FAQ entry would help the users, serving as public documentation for this project. - After every iteration, update all related documentation according to the change.
- After every frontend change, verify the results using playwright. - After every frontend change, verify the results using an ephemeral playwright.
- After every iteration, do a git commit with a brief summary of the changes as a commit message. - After every iteration, do a git commit with a brief summary of the changes as a commit message.
- Keep changes small and commit often. If one iteration encompasses many smaller tasks with more than one commit, create a git branch and do the commits there. Let me review the branch before merging it back to master. - Keep changes small and commit often. If one iteration encompasses many smaller tasks with more than one commit, create a git branch and do the commits there. Let me review the branch before merging it back to master.
- When multiple commits are necessary, pause after every commit and ask the user to give a command to proceed.
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated), never revert them and never restore the old state. Assume that those changes happened with intent. - If you find unexpected changes in the code (deletions, changes, diff results that were not communicated), never revert them and never restore the old state. Assume that those changes happened with intent.
- Never use `git restore`, `git checkout --`, reset commands, or equivalent rollback actions to discard local changes unless the user explicitly asks for that exact rollback. - Never use `git restore`, `git checkout --`, reset commands, or equivalent rollback actions to discard local changes unless the user explicitly asks for that exact rollback.
- If a required tool is missing (for example `dotnet-ef`), install/configure the tool (prefer repo-local setup such as `dotnet tool manifest`) instead of weakening validations or muting warnings. If installation is blocked, stop and ask before changing validation strictness. - If a required tool is missing (for example `dotnet-ef`), install/configure the tool (prefer repo-local setup such as `dotnet tool manifest`) instead of weakening validations or muting warnings. If installation is blocked, stop and ask before changing validation strictness.

View File

@@ -47,6 +47,7 @@ Backend state persistence:
Gameplay capabilities now include: Gameplay capabilities now include:
- Instant skill filtering in the character panel (filters live while typing and hides non-matching skills/groups) - Instant skill filtering in the character panel (filters live while typing and hides non-matching skills/groups)
- Supported campaign rulesets include D6 System, D&D 5e, and Rolemaster
- Skill groups per character with skill prototypes (create/edit/delete groups, assign/reassign skills, and prefill new skill forms from group defaults) - Skill groups per character with skill prototypes (create/edit/delete groups, assign/reassign skills, and prefill new skill forms from group defaults)
- Skill and skill-group deletion flows - Skill and skill-group deletion flows
- GM-driven character owner transfer within campaign management flows - GM-driven character owner transfer within campaign management flows
@@ -61,12 +62,21 @@ Gameplay capabilities now include:
- Campaign management supports character deletion by character owner or admin - Campaign management supports character deletion by character owner or admin
- Shared top header control across all authenticated workspace screens (play, campaign management, admin) - Shared top header control across all authenticated workspace screens (play, campaign management, admin)
- Admin user management is integrated into workspace screen toggles (`Play`, `Campaign Management`, `Admin`) - Admin user management is integrated into workspace screen toggles (`Play`, `Campaign Management`, `Admin`)
- Rolemaster expression validation now accepts generic standard expressions such as `d10`, `2d10+48`, `15d10`, and `d100-15`; `d100!+85` remains the special open-ended percentile form
- Rolemaster open-ended percentile skills and skill-group defaults now persist a nullable `FumbleRange` field, while D6 and D&D rows migrate forward unchanged
- Rolemaster create/edit forms now keep the expression authoritative, show generic Rolemaster syntax help, and reveal `FumbleRange` only when the expression is an open-ended percentile roll
- Rolemaster roll execution now supports generic standard Rolemaster rolls (`NdS+x`, with implicit count `1` for `dS`) plus open-ended percentile (`d100!+x`) with recursive high-end chaining and low-end subtraction based on `FumbleRange`; low-end trigger rolls are shown for auditability but do not count toward the total
- Compact campaign-log summaries stay dense for Rolemaster rolls, while lazy-loaded roll detail includes ordered die metadata for each open-ended follow-up step
- Startup migration coverage is validated against a copied temp-file instance of `RpgRoller/App_Data/rpgroller.development.db` before feature work is considered complete
## Prerequisites ## Prerequisites
- .NET SDK 10.0+ - .NET SDK 10.0+
- PowerShell 7+ - PowerShell 7+
- Node.js 22+
- Run `dotnet tool restore` once to enable the repo-local `dotnet-ef` command. - Run `dotnet tool restore` once to enable the repo-local `dotnet-ef` command.
- Run `npm ci` once to install the repo-local Playwright toolchain.
- Run `npm exec playwright install chromium` once to install the browser used by local smoke tests.
## Local Development ## Local Development
@@ -80,6 +90,21 @@ Gameplay capabilities now include:
``` ```
3. Open `http://localhost:5000` (or the port shown in the console). 3. Open `http://localhost:5000` (or the port shown in the console).
Playwright helpers:
- Install/update browser dependencies:
```powershell
npm exec playwright install chromium
```
- Run the checked-in smoke test against an isolated temp SQLite database:
```powershell
pwsh ./scripts/run-playwright.ps1
```
- Run the Playwright suite directly when the app is already running:
```powershell
npm run e2e
```
VS Code F5 debug profiles are available in `.vscode/launch.json`: VS Code F5 debug profiles are available in `.vscode/launch.json`:
- `RpgRoller: Server` - `RpgRoller: Server`
@@ -99,6 +124,7 @@ dotnet dotnet-ef migrations add <MigrationName> --project RpgRoller/RpgRoller.cs
- Runtime frontend is Blazor Server with interactive components. - Runtime frontend is Blazor Server with interactive components.
- Browser interop is in `RpgRoller/wwwroot/js/rpgroller-api.js`. - Browser interop is in `RpgRoller/wwwroot/js/rpgroller-api.js`.
- Root static assets such as `styles.css` are linked through Blazor's `@Assets[...]` pipeline so deploys get fingerprinted cache-busting URLs automatically.
- Workspace reads are resolved server-side through scoped query services; browser interop remains for browser-only concerns such as session storage, SSE wiring, and DOM helpers. - Workspace reads are resolved server-side through scoped query services; browser interop remains for browser-only concerns such as session storage, SSE wiring, and DOM helpers.
- Live workspace refreshes now compare separate roster, per-character sheet, and log versions so unrelated state changes do not force a full roster + sheet + log reload. - Live workspace refreshes now compare separate roster, per-character sheet, and log versions so unrelated state changes do not force a full roster + sheet + log reload.
- Workspace campaign data is loaded in bounded slices: visible campaign summaries, a selected campaign roster, a selected character sheet, and a 25-row incremental log window backed by `/api/campaigns/{campaignId}/log/page`. - Workspace campaign data is loaded in bounded slices: visible campaign summaries, a selected campaign roster, a selected character sheet, and a 25-row incremental log window backed by `/api/campaigns/{campaignId}/log/page`.
@@ -112,7 +138,7 @@ dotnet dotnet-ef migrations add <MigrationName> --project RpgRoller/RpgRoller.cs
```powershell ```powershell
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings
``` ```
- Regression tests enforce payload budgets for the hottest contracts: character sheet reads, initial log page loads, incremental log updates, and roll mutation responses. - Regression tests enforce payload budgets for the hottest contracts: character sheet reads, initial log page loads, incremental log updates, roll mutation responses, and lazy-loaded Rolemaster roll detail payloads.
- Coverage gate: - Coverage gate:
```powershell ```powershell
pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70 pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70

View File

@@ -65,6 +65,49 @@ public sealed class CampaignApiTests : ApiTestBase
Assert.Equal(otherCampaign.Id, updatedCharacter.CampaignId); Assert.Equal(otherCampaign.Id, updatedCharacter.CampaignId);
} }
[Fact]
public async Task CampaignCreation_AcceptsRolemasterRuleset()
{
using var factory = CreateFactory(2, 2, 2);
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
await RegisterAsync(gmClient, "gm-rm-api", "Password123", "Game Master");
await LoginAsync(gmClient, "gm-rm-api", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Shadow World", "rolemaster"));
Assert.Equal("rolemaster", campaign.RulesetId);
}
[Fact]
public async Task RolemasterSkillDefinitions_RoundTripFumbleRangeThroughApi()
{
using var factory = CreateFactory(88, 42, 17);
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
await RegisterAsync(gmClient, "gm-rm-skill", "Password123", "Game Master");
await LoginAsync(gmClient, "gm-rm-skill", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Shadow World", "rolemaster"));
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(gmClient, "/api/characters", new("Kalen", campaign.Id));
var missingFumbleRange = await gmClient.PostAsJsonAsync($"/api/characters/{character.Id}/skills", new CreateSkillRequest("Bad Open Ended", "d100!+35", 0, false));
Assert.Equal(HttpStatusCode.BadRequest, missingFumbleRange.StatusCode);
var group = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(gmClient, $"/api/characters/{character.Id}/skill-groups", new("Perception", "d100!+15", 0, false, 5));
Assert.Equal(5, group.FumbleRange);
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(gmClient, $"/api/characters/{character.Id}/skills", new("Awareness", "d100!+35", 0, false, group.Id, 3));
Assert.Equal(3, skill.FumbleRange);
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(gmClient, $"/api/skills/{skill.Id}", new("Awareness", "d100!+45", 0, false, group.Id, 4));
Assert.Equal(4, updatedSkill.FumbleRange);
var sheet = await GetAsync<CharacterSheet>(gmClient, $"/api/characters/{character.Id}/sheet");
Assert.Equal(5, Assert.Single(sheet.SkillGroups).FumbleRange);
Assert.Equal(4, Assert.Single(sheet.Skills).FumbleRange);
}
[Fact] [Fact]
public async Task SkillGroupsAndOwnerTransfer_WorkThroughApi() public async Task SkillGroupsAndOwnerTransfer_WorkThroughApi()
{ {

View File

@@ -0,0 +1,88 @@
namespace RpgRoller.Tests;
public sealed class RolemasterApiTests : ApiTestBase
{
public RolemasterApiTests(WebApplicationFactory<Program> factory) : base(factory)
{
}
[Fact]
public async Task RolemasterRollEndpoints_ExecuteGenericRolemasterExpressions()
{
using var factory = CreateFactory(8, 6, 74);
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
await RegisterAsync(client, "rolemaster-api", "Password123", "Rolemaster Api");
await LoginAsync(client, "rolemaster-api", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Rolemaster", "rolemaster"));
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(client, "/api/characters", new("Hero", campaign.Id));
var initiative = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Healing", "2d10+48", 0, false));
var perception = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Perception", "d100-15", 0, false));
var initiativeRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{initiative.Id}/roll", new("public"));
var percentileRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{perception.Id}/roll", new("public"));
var logPage = await GetAsync<CampaignLogPage>(client, $"/api/campaigns/{campaign.Id}/log/page?limit=10");
Assert.Equal(62, initiativeRoll.Result);
Assert.Equal("8+6+48=62", initiativeRoll.Breakdown);
Assert.All(initiativeRoll.Dice, die => Assert.Equal(RollDieKinds.RolemasterStandard, die.Kind));
Assert.Equal(59, percentileRoll.Result);
Assert.Equal("74-15=59", percentileRoll.Breakdown);
Assert.Equal(RollDieKinds.RolemasterStandard, Assert.Single(percentileRoll.Dice).Kind);
Assert.Equal(2, logPage.Entries.Length);
Assert.Equal("8 + 6 | rolemaster", logPage.Entries[0].SummaryText);
Assert.Equal("74 | rolemaster", logPage.Entries[1].SummaryText);
}
[Fact]
public async Task RolemasterOpenEndedRolls_AppearInLogPageAndDetail()
{
using var factory = CreateFactory(5, 97, 100, 12);
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
await RegisterAsync(client, "rolemaster-open-api", "Password123", "Rolemaster Open Api");
await LoginAsync(client, "rolemaster-open-api", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Rolemaster Open", "rolemaster"));
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(client, "/api/characters", new("Hero", campaign.Id));
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Awareness", "d100!+85", 0, false, null, 5));
var roll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{skill.Id}/roll", new("public"));
var logPage = await GetAsync<CampaignLogPage>(client, $"/api/campaigns/{campaign.Id}/log/page?limit=5");
var detail = await GetAsync<CampaignRollDetail>(client, $"/api/rolls/{roll.RollId}");
Assert.Equal(-124, roll.Result);
Assert.Equal("(05) -97 -100 -12 +85 = -124", roll.Breakdown);
Assert.Equal("(05) -97 -100 -12 | open-ended low", Assert.Single(logPage.Entries).SummaryText);
Assert.Equal(roll.Breakdown, detail.Breakdown);
Assert.Collection(
detail.Dice,
die =>
{
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
Assert.Equal(1, die.Sequence);
Assert.Null(die.SignedContribution);
},
die =>
{
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
Assert.Equal(2, die.Sequence);
Assert.Equal(-97, die.SignedContribution);
},
die =>
{
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
Assert.Equal(3, die.Sequence);
Assert.Equal(-100, die.SignedContribution);
},
die =>
{
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
Assert.Equal(4, die.Sequence);
Assert.Equal(-12, die.SignedContribution);
});
}
}

View File

@@ -13,7 +13,9 @@ public sealed class SystemApiTests : ApiTestBase
using var client = factory.CreateClient(new() { AllowAutoRedirect = false }); using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
var rulesets = await GetAsync<IReadOnlyList<RulesetDefinition>>(client, "/api/rulesets"); var rulesets = await GetAsync<IReadOnlyList<RulesetDefinition>>(client, "/api/rulesets");
Assert.Equal(2, rulesets.Count); Assert.Equal(3, rulesets.Count);
var rolemaster = Assert.Single(rulesets, ruleset => ruleset.Id == "rolemaster");
Assert.Equal("Rolemaster", rolemaster.Name);
await RegisterAsync(client, "sse", "Password123", "Sse User"); await RegisterAsync(client, "sse", "Password123", "Sse User");
await LoginAsync(client, "sse", "Password123"); await LoginAsync(client, "sse", "Password123");

View File

@@ -1,7 +1,9 @@
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using RpgRoller.Data; using RpgRoller.Data;
using RpgRoller.Hosting; using RpgRoller.Hosting;
@@ -125,6 +127,16 @@ public sealed class HostingCoverageTests
Assert.Contains("WildDice", columns); Assert.Contains("WildDice", columns);
Assert.Contains("AllowFumble", columns); Assert.Contains("AllowFumble", columns);
Assert.Contains("FumbleRange", columns);
using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand();
skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');";
using var skillGroupsTableInfoReader = skillGroupsTableInfoCommand.ExecuteReader();
var skillGroupColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (skillGroupsTableInfoReader.Read())
skillGroupColumns.Add(skillGroupsTableInfoReader.GetString(1));
Assert.Contains("FumbleRange", skillGroupColumns);
using var rollTableInfoCommand = verifyConnection.CreateCommand(); using var rollTableInfoCommand = verifyConnection.CreateCommand();
rollTableInfoCommand.CommandText = "PRAGMA table_info('RollLogEntries');"; rollTableInfoCommand.CommandText = "PRAGMA table_info('RollLogEntries');";
@@ -183,5 +195,130 @@ public sealed class HostingCoverageTests
rolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226160859_AddAuthorizationRolesAndCampaignDeletion';"; rolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226160859_AddAuthorizationRolesAndCampaignDeletion';";
var rolesHistoryCount = Convert.ToInt32(rolesHistoryCommand.ExecuteScalar()); var rolesHistoryCount = Convert.ToInt32(rolesHistoryCommand.ExecuteScalar());
Assert.Equal(1, rolesHistoryCount); Assert.Equal(1, rolesHistoryCount);
using var rolemasterHistoryCommand = verifyConnection.CreateCommand();
rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';";
var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar());
Assert.Equal(1, rolemasterHistoryCount);
}
[Fact]
public void InitializeRpgRollerState_MigratesCopiedDevelopmentDatabaseAndPreservesD6Rolling()
{
var sourceDbPath = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "RpgRoller", "App_Data", "rpgroller.development.db");
var copiedDbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-dev-copy-{Guid.NewGuid():N}.db");
File.Copy(Path.GetFullPath(sourceDbPath), copiedDbPath, overwrite: true);
Guid skillId;
Guid ownerUserId;
Guid characterId;
var campaignCountBefore = 0;
var skillCountBefore = 0;
using (var connection = new SqliteConnection($"Data Source={copiedDbPath}"))
{
connection.Open();
using var countsCommand = connection.CreateCommand();
countsCommand.CommandText = """
SELECT (SELECT COUNT(*) FROM Campaigns),
(SELECT COUNT(*) FROM Skills);
""";
using var countsReader = countsCommand.ExecuteReader();
Assert.True(countsReader.Read());
campaignCountBefore = countsReader.GetInt32(0);
skillCountBefore = countsReader.GetInt32(1);
using var existingSkillCommand = connection.CreateCommand();
existingSkillCommand.CommandText = """
SELECT s.Id, c.OwnerUserId, c.Id
FROM Skills s
INNER JOIN Characters c ON c.Id = s.CharacterId
INNER JOIN Campaigns cp ON cp.Id = c.CampaignId
WHERE cp.Ruleset = 'D6'
ORDER BY s.Name
LIMIT 1;
""";
using var existingSkillReader = existingSkillCommand.ExecuteReader();
Assert.True(existingSkillReader.Read());
skillId = Guid.Parse(existingSkillReader.GetString(0));
ownerUserId = Guid.Parse(existingSkillReader.GetString(1));
characterId = Guid.Parse(existingSkillReader.GetString(2));
using var sessionCommand = connection.CreateCommand();
sessionCommand.CommandText = """
INSERT INTO Sessions ("Token", "UserId", "CreatedAtUtc")
VALUES ($token, $userId, $createdAtUtc);
""";
var tokenParameter = sessionCommand.CreateParameter();
tokenParameter.ParameterName = "$token";
tokenParameter.Value = "migration-test-session";
sessionCommand.Parameters.Add(tokenParameter);
var userParameter = sessionCommand.CreateParameter();
userParameter.ParameterName = "$userId";
userParameter.Value = ownerUserId.ToString();
sessionCommand.Parameters.Add(userParameter);
var createdAtParameter = sessionCommand.CreateParameter();
createdAtParameter.ParameterName = "$createdAtUtc";
createdAtParameter.Value = DateTimeOffset.UtcNow.ToString("O");
sessionCommand.Parameters.Add(createdAtParameter);
_ = sessionCommand.ExecuteNonQuery();
}
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
ContentRootPath = Path.GetTempPath(),
EnvironmentName = Environments.Development
});
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:RpgRoller"] = $"Data Source={copiedDbPath}"
});
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
using var app = builder.Build();
app.InitializeRpgRollerState();
using var scope = app.Services.CreateScope();
var game = scope.ServiceProvider.GetRequiredService<IGameService>();
var rollResult = game.RollSkill("migration-test-session", skillId, "public");
Assert.True(rollResult.Succeeded);
Assert.NotEmpty(ServiceTestSupport.GetValue(rollResult).Dice);
var migratedSheet = ServiceTestSupport.GetValue(game.GetCharacterSheet("migration-test-session", characterId));
Assert.Contains(migratedSheet.Skills, skill => skill.Id == skillId);
using var verifyConnection = new SqliteConnection($"Data Source={copiedDbPath}");
verifyConnection.Open();
using var countsAfterCommand = verifyConnection.CreateCommand();
countsAfterCommand.CommandText = """
SELECT (SELECT COUNT(*) FROM Campaigns),
(SELECT COUNT(*) FROM Skills);
""";
using var countsAfterReader = countsAfterCommand.ExecuteReader();
Assert.True(countsAfterReader.Read());
Assert.Equal(campaignCountBefore, countsAfterReader.GetInt32(0));
Assert.Equal(skillCountBefore, countsAfterReader.GetInt32(1));
using var skillsTableInfoCommand = verifyConnection.CreateCommand();
skillsTableInfoCommand.CommandText = "PRAGMA table_info('Skills');";
using var skillsTableInfoReader = skillsTableInfoCommand.ExecuteReader();
var skillColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (skillsTableInfoReader.Read())
skillColumns.Add(skillsTableInfoReader.GetString(1));
Assert.Contains("FumbleRange", skillColumns);
using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand();
skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');";
using var skillGroupsTableInfoReader = skillGroupsTableInfoCommand.ExecuteReader();
var skillGroupColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (skillGroupsTableInfoReader.Read())
skillGroupColumns.Add(skillGroupsTableInfoReader.GetString(1));
Assert.Contains("FumbleRange", skillGroupColumns);
} }
} }

View File

@@ -87,6 +87,29 @@ public sealed class PayloadBudgetTests
AssertPayloadWithinBudget(incrementalPage, 2 * 1024, "incremental log update"); AssertPayloadWithinBudget(incrementalPage, 2 * 1024, "incremental log update");
} }
[Fact]
public void RolemasterCampaignLogInitialPagePayload_StaysWithinBudget()
{
using var harness = ServiceTestSupport.CreateHarness(CreateRolemasterOpenEndedRolls(90));
var service = harness.Service;
service.Register("gm-rm-log-budget", "Password123", "GM");
service.Register("owner-rm-log-budget", "Password123", "Owner");
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-rm-log-budget", "Password123")).SessionToken;
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-rm-log-budget", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Payload Log", "rolemaster"));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Open Hero", campaign.Id));
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Awareness", "d100!+85", 0, false, null, 5));
for (var i = 0; i < 25; i++)
_ = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public"));
var page = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, limit: 25));
AssertPayloadWithinBudget(page, 8 * 1024, "initial rolemaster log page");
}
[Fact] [Fact]
public void RollResultPayload_StaysWithinJsInteropBudget() public void RollResultPayload_StaysWithinJsInteropBudget()
{ {
@@ -107,6 +130,41 @@ public sealed class PayloadBudgetTests
AssertPayloadWithinBudget(roll, 16 * 1024, "roll mutation response"); AssertPayloadWithinBudget(roll, 16 * 1024, "roll mutation response");
} }
[Fact]
public void RolemasterRollDetailPayload_StaysWithinBudget_AndRolemasterMetadataRemainsLazy()
{
using var harness = ServiceTestSupport.CreateHarness([96, 100, 100, 100, 100, 97, 12]);
var service = harness.Service;
service.Register("gm-rm-detail-budget", "Password123", "GM");
service.Register("owner-rm-detail-budget", "Password123", "Owner");
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-rm-detail-budget", "Password123")).SessionToken;
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-rm-detail-budget", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Payload Detail", "rolemaster"));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Detail Hero", campaign.Id));
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Sense", "d100!+85", 0, false, null, 5));
var roll = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public"));
var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, limit: 5));
var detail = ServiceTestSupport.GetValue(service.GetRollDetail(gmSession, roll.RollId));
AssertPayloadWithinBudget(detail, 4 * 1024, "rolemaster roll detail");
var rollJson = JsonSerializer.Serialize(roll, SerializerOptions);
var logPageJson = JsonSerializer.Serialize(logPage, SerializerOptions);
var detailJson = JsonSerializer.Serialize(detail, SerializerOptions);
Assert.DoesNotContain("\"signedContribution\":null", rollJson, StringComparison.Ordinal);
Assert.DoesNotContain("\"signedContribution\"", logPageJson, StringComparison.Ordinal);
Assert.DoesNotContain("\"sequence\"", logPageJson, StringComparison.Ordinal);
Assert.DoesNotContain("\"breakdown\"", logPageJson, StringComparison.Ordinal);
Assert.Contains("\"kind\":\"rolemaster-open-ended-initial\"", detailJson, StringComparison.Ordinal);
Assert.Contains("\"signedContribution\":96", detailJson, StringComparison.Ordinal);
Assert.Contains("\"sequence\":6", detailJson, StringComparison.Ordinal);
}
private static void AssertPayloadWithinBudget<T>(T payload, int maxBytes, string label) private static void AssertPayloadWithinBudget<T>(T payload, int maxBytes, string label)
{ {
var byteCount = JsonSerializer.SerializeToUtf8Bytes(payload, SerializerOptions).Length; var byteCount = JsonSerializer.SerializeToUtf8Bytes(payload, SerializerOptions).Length;
@@ -123,5 +181,15 @@ public sealed class PayloadBudgetTests
return scriptedRolls; return scriptedRolls;
} }
private static int[] CreateRolemasterOpenEndedRolls(int count)
{
var values = new[] { 96, 100, 12 };
var scriptedRolls = new int[count];
for (var i = 0; i < count; i++)
scriptedRolls[i] = values[i % values.Length];
return scriptedRolls;
}
private static readonly JsonSerializerOptions SerializerOptions = RpgRollerJson.CreateSerializerOptions(); private static readonly JsonSerializerOptions SerializerOptions = RpgRollerJson.CreateSerializerOptions();
} }

View File

@@ -7,28 +7,53 @@ public sealed class DiceRulesTests
{ {
Assert.Equal(RulesetKind.D6, DiceRules.TryParseRulesetId("d6")); Assert.Equal(RulesetKind.D6, DiceRules.TryParseRulesetId("d6"));
Assert.Equal(RulesetKind.Dnd5e, DiceRules.TryParseRulesetId("dnd5e")); Assert.Equal(RulesetKind.Dnd5e, DiceRules.TryParseRulesetId("dnd5e"));
Assert.Equal(RulesetKind.Rolemaster, DiceRules.TryParseRulesetId("rolemaster"));
Assert.Null(DiceRules.TryParseRulesetId("unknown")); Assert.Null(DiceRules.TryParseRulesetId("unknown"));
var d6 = DiceRules.ParseExpression(RulesetKind.D6, "5D+4"); var d6 = DiceRules.ParseExpression(RulesetKind.D6, "5D+4");
var dnd = DiceRules.ParseExpression(RulesetKind.Dnd5e, "2d12+2"); var dnd = DiceRules.ParseExpression(RulesetKind.Dnd5e, "2d12+2");
var rolemasterImplicitSingle = DiceRules.ParseExpression(RulesetKind.Rolemaster, "d10");
var rolemasterManyDice = DiceRules.ParseExpression(RulesetKind.Rolemaster, "15d10-15");
var rolemasterPercentile = DiceRules.ParseExpression(RulesetKind.Rolemaster, "d100+4");
var rolemasterOpenEnded = DiceRules.ParseExpression(RulesetKind.Rolemaster, "1d100!+85");
var emptyExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, ""); var emptyExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, "");
var badFormat = DiceRules.ParseExpression(RulesetKind.Dnd5e, "abc"); var badFormat = DiceRules.ParseExpression(RulesetKind.Dnd5e, "abc");
var tooManyDice = DiceRules.ParseExpression(RulesetKind.D6, "51D+1"); var tooManyDice = DiceRules.ParseExpression(RulesetKind.D6, "51D+1");
var tooManySides = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d1001"); var tooManySides = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d1001");
var tooLargeModifier = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d20+1001"); var tooLargeModifier = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d20+1001");
var negativeDndModifier = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d20-1");
var invalidRolemasterOpenEndedFormat = DiceRules.ParseExpression(RulesetKind.Rolemaster, "2d10!+1");
var tooNegativeRolemasterModifier = DiceRules.ParseExpression(RulesetKind.Rolemaster, "d100-1001");
var unknownRulesetExpression = DiceRules.ParseExpression((RulesetKind)99, "1d20+1"); var unknownRulesetExpression = DiceRules.ParseExpression((RulesetKind)99, "1d20+1");
Assert.True(d6.Succeeded); Assert.True(d6.Succeeded);
Assert.True(dnd.Succeeded); Assert.True(dnd.Succeeded);
Assert.True(rolemasterImplicitSingle.Succeeded);
Assert.True(rolemasterManyDice.Succeeded);
Assert.True(rolemasterPercentile.Succeeded);
Assert.True(rolemasterOpenEnded.Succeeded);
Assert.False(emptyExpression.Succeeded); Assert.False(emptyExpression.Succeeded);
Assert.False(badFormat.Succeeded); Assert.False(badFormat.Succeeded);
Assert.False(tooManyDice.Succeeded); Assert.False(tooManyDice.Succeeded);
Assert.False(tooManySides.Succeeded); Assert.False(tooManySides.Succeeded);
Assert.False(tooLargeModifier.Succeeded); Assert.False(tooLargeModifier.Succeeded);
Assert.False(negativeDndModifier.Succeeded);
Assert.False(invalidRolemasterOpenEndedFormat.Succeeded);
Assert.False(tooNegativeRolemasterModifier.Succeeded);
Assert.False(unknownRulesetExpression.Succeeded); Assert.False(unknownRulesetExpression.Succeeded);
Assert.Equal("d10", rolemasterImplicitSingle.Value!.Canonical);
Assert.Equal(DiceExpressionKind.Standard, rolemasterImplicitSingle.Value.Kind);
Assert.Equal("15d10-15", rolemasterManyDice.Value!.Canonical);
Assert.Equal(DiceExpressionKind.Standard, rolemasterManyDice.Value.Kind);
Assert.Equal("d100+4", rolemasterPercentile.Value!.Canonical);
Assert.Equal(DiceExpressionKind.Standard, rolemasterPercentile.Value.Kind);
Assert.Equal("d100!+85", rolemasterOpenEnded.Value!.Canonical);
Assert.Equal(DiceExpressionKind.RolemasterOpenEndedPercentile, rolemasterOpenEnded.Value.Kind);
Assert.Equal("d6", DiceRules.ToRulesetId(RulesetKind.D6)); Assert.Equal("d6", DiceRules.ToRulesetId(RulesetKind.D6));
Assert.Equal("dnd5e", DiceRules.ToRulesetId(RulesetKind.Dnd5e)); Assert.Equal("dnd5e", DiceRules.ToRulesetId(RulesetKind.Dnd5e));
Assert.Equal("rolemaster", DiceRules.ToRulesetId(RulesetKind.Rolemaster));
Assert.Throws<ArgumentOutOfRangeException>(() => DiceRules.ToRulesetId((RulesetKind)99)); Assert.Throws<ArgumentOutOfRangeException>(() => DiceRules.ToRulesetId((RulesetKind)99));
} }
} }

View File

@@ -14,9 +14,11 @@ public sealed class ServiceCampaignTests
service.Register("gm", "Password123", "GM"); service.Register("gm", "Password123", "GM");
var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken; var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Name", "d6")); var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Name", "d6"));
var rolemasterCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Name", "rolemaster"));
var invalidRuleset = service.CreateCampaign(gmSession, "Name 2", "unknown"); var invalidRuleset = service.CreateCampaign(gmSession, "Name 2", "unknown");
Assert.False(invalidRuleset.Succeeded); Assert.False(invalidRuleset.Succeeded);
Assert.Equal("rolemaster", rolemasterCampaign.RulesetId);
var noCampaignCharacter = service.CreateCharacter(gmSession, "Hero", Guid.NewGuid()); var noCampaignCharacter = service.CreateCharacter(gmSession, "Hero", Guid.NewGuid());
Assert.False(noCampaignCharacter.Succeeded); Assert.False(noCampaignCharacter.Succeeded);

View File

@@ -92,4 +92,31 @@ public sealed class ServicePersistenceTests
Assert.False(invalidExpressionHarness.Service.RollSkill(ownerSession, skill.Id, "public").Succeeded); Assert.False(invalidExpressionHarness.Service.RollSkill(ownerSession, skill.Id, "public").Succeeded);
Assert.False(service.GetCampaignLog(string.Empty, campaign.Id).Succeeded); Assert.False(service.GetCampaignLog(string.Empty, campaign.Id).Succeeded);
} }
[Fact]
public void RolemasterFumbleRange_PersistsAcrossDatabaseReload()
{
using var harness = ServiceTestSupport.CreateHarness();
var service = harness.Service;
service.Register("gm-rm-persist", "Password123", "GM");
service.Register("owner-rm-persist", "Password123", "Owner");
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-rm-persist", "Password123")).SessionToken;
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-rm-persist", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Persistence", "rolemaster"));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Loremaster", campaign.Id));
var group = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Perception", "d100!+25", 0, false, 5));
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Read Runes", "d100!+35", 0, false, group.Id, 3));
using var reloadedHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath);
var reloadedSheet = ServiceTestSupport.GetValue(reloadedHarness.Service.GetCharacterSheet(ownerSession, character.Id));
var reloadedGroup = Assert.Single(reloadedSheet.SkillGroups, current => current.Id == group.Id);
Assert.Equal(5, reloadedGroup.FumbleRange);
var reloadedSkill = Assert.Single(reloadedSheet.Skills, current => current.Id == skill.Id);
Assert.Equal(3, reloadedSkill.FumbleRange);
}
} }

View File

@@ -0,0 +1,164 @@
namespace RpgRoller.Tests;
public sealed class ServiceRolemasterRollTests
{
[Fact]
public void RollSkill_RolemasterStandardMultiDie_ComputesTotalAndTagsDice()
{
using var harness = ServiceTestSupport.CreateHarness(7, 10);
var service = harness.Service;
service.Register("gm-init", "Password123", "GM");
var session = ServiceTestSupport.GetValue(service.Login("gm-init", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster", "rolemaster"));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Healing", "2d10+48", 0, false));
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5));
Assert.Equal(65, roll.Result);
Assert.Equal("7+10+48=65", roll.Breakdown);
Assert.Equal("7 + 10 | rolemaster", Assert.Single(logPage.Entries).SummaryText);
Assert.Collection(
roll.Dice,
die =>
{
Assert.Equal(7, die.Roll);
Assert.Equal(1, die.Sequence);
Assert.Equal(RollDieKinds.RolemasterStandard, die.Kind);
Assert.Equal(7, die.SignedContribution);
},
die =>
{
Assert.Equal(10, die.Roll);
Assert.Equal(2, die.Sequence);
Assert.Equal(RollDieKinds.RolemasterStandard, die.Kind);
Assert.Equal(10, die.SignedContribution);
});
}
[Fact]
public void RollSkill_RolemasterStandardSingleDie_ComputesTotalAndTagsDice()
{
using var harness = ServiceTestSupport.CreateHarness(73);
var service = harness.Service;
service.Register("gm-percentile", "Password123", "GM");
var session = ServiceTestSupport.GetValue(service.Login("gm-percentile", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster", "rolemaster"));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Perception", "d100-15", 0, false));
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5));
Assert.Equal(58, roll.Result);
Assert.Equal("73-15=58", roll.Breakdown);
Assert.Equal("73 | rolemaster", Assert.Single(logPage.Entries).SummaryText);
var die = Assert.Single(roll.Dice);
Assert.Equal(73, die.Roll);
Assert.Equal(1, die.Sequence);
Assert.Equal(RollDieKinds.RolemasterStandard, die.Kind);
Assert.Equal(73, die.SignedContribution);
}
[Fact]
public void RollSkill_RolemasterOpenEndedHigh_RecursesAndBuildsReadableLogSummary()
{
using var harness = ServiceTestSupport.CreateHarness(97, 96, 45);
var service = harness.Service;
service.Register("gm-open-high", "Password123", "GM");
var session = ServiceTestSupport.GetValue(service.Login("gm-open-high", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster", "rolemaster"));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Awareness", "d100!+85", 0, false, null, 5));
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
var detail = ServiceTestSupport.GetValue(service.GetRollDetail(session, roll.RollId));
var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5));
Assert.Equal(323, roll.Result);
Assert.Equal("97+96+45+85=323", roll.Breakdown);
Assert.Equal("97 + 96 + 45 | open-ended high", Assert.Single(logPage.Entries).SummaryText);
Assert.Equal(roll.Breakdown, detail.Breakdown);
Assert.Collection(
detail.Dice,
die =>
{
Assert.Equal(97, die.Roll);
Assert.Equal(1, die.Sequence);
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
Assert.Equal(97, die.SignedContribution);
Assert.False(die.Added);
},
die =>
{
Assert.Equal(96, die.Roll);
Assert.Equal(2, die.Sequence);
Assert.Equal(RollDieKinds.RolemasterOpenEndedHigh, die.Kind);
Assert.Equal(96, die.SignedContribution);
Assert.True(die.Added);
},
die =>
{
Assert.Equal(45, die.Roll);
Assert.Equal(3, die.Sequence);
Assert.Equal(RollDieKinds.RolemasterOpenEndedHigh, die.Kind);
Assert.Equal(45, die.SignedContribution);
Assert.True(die.Added);
});
}
[Fact]
public void RollSkill_RolemasterOpenEndedLow_SubtractsRecursiveHighChain()
{
using var harness = ServiceTestSupport.CreateHarness(5, 97, 100, 12);
var service = harness.Service;
service.Register("gm-open-low", "Password123", "GM");
var session = ServiceTestSupport.GetValue(service.Login("gm-open-low", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster", "rolemaster"));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Awareness", "d100!+85", 0, false, null, 5));
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5));
Assert.Equal(-124, roll.Result);
Assert.Equal("(05) -97 -100 -12 +85 = -124", roll.Breakdown);
Assert.Equal("(05) -97 -100 -12 | open-ended low", Assert.Single(logPage.Entries).SummaryText);
Assert.Collection(
roll.Dice,
die =>
{
Assert.Equal(5, die.Roll);
Assert.Equal(1, die.Sequence);
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
Assert.Null(die.SignedContribution);
},
die =>
{
Assert.Equal(97, die.Roll);
Assert.Equal(2, die.Sequence);
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
Assert.Equal(-97, die.SignedContribution);
},
die =>
{
Assert.Equal(100, die.Roll);
Assert.Equal(3, die.Sequence);
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
Assert.Equal(-100, die.SignedContribution);
},
die =>
{
Assert.Equal(12, die.Roll);
Assert.Equal(4, die.Sequence);
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
Assert.Equal(-12, die.SignedContribution);
});
}
}

View File

@@ -174,4 +174,55 @@ public sealed class ServiceSkillGroupAndOwnershipTests
var campaignAfterDeletes = ServiceTestSupport.GetValue(service.GetCampaign(gmSession, campaign.Id)); var campaignAfterDeletes = ServiceTestSupport.GetValue(service.GetCampaign(gmSession, campaign.Id));
Assert.Empty(campaignAfterDeletes.Characters); Assert.Empty(campaignAfterDeletes.Characters);
} }
[Fact]
public void RolemasterSkillDefinitions_CanonicalizeAndKeepLegacyNegativeModifierRules()
{
using var harness = ServiceTestSupport.CreateHarness();
var service = harness.Service;
service.Register("gm-rm", "Password123", "GM");
service.Register("owner-rm", "Password123", "Owner");
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-rm", "Password123")).SessionToken;
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-rm", "Password123")).SessionToken;
var rolemasterCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Shadow World", "rolemaster"));
var dndCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Forgotten Realms", "dnd5e"));
var rolemasterCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Harn", rolemasterCampaign.Id));
var dndCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Mage", dndCampaign.Id));
var negativeDndSkill = service.CreateSkill(ownerSession, dndCharacter.Id, "Invalid", "1d20-1", 0, false);
Assert.False(negativeDndSkill.Succeeded);
var invalidRolemasterOptions = service.CreateSkillGroup(ownerSession, rolemasterCharacter.Id, "Invalid", "2d10-15", 3, true);
Assert.False(invalidRolemasterOptions.Succeeded);
var rolemasterGroup = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, rolemasterCharacter.Id, "Awareness", "d100!+15", 0, false, 5));
Assert.Equal("d100!+15", rolemasterGroup.DiceRollDefinition);
Assert.Equal(0, rolemasterGroup.WildDice);
Assert.False(rolemasterGroup.AllowFumble);
Assert.Equal(5, rolemasterGroup.FumbleRange);
var percentileWithFumbleRange = service.CreateSkill(ownerSession, rolemasterCharacter.Id, "Bad Percentile", "1d100-20", 0, false, null, 5);
Assert.False(percentileWithFumbleRange.Succeeded);
var percentileSkill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, rolemasterCharacter.Id, "Perception", "1d100-20", 0, false, rolemasterGroup.Id));
Assert.Equal("d100-20", percentileSkill.DiceRollDefinition);
Assert.Equal(0, percentileSkill.WildDice);
Assert.False(percentileSkill.AllowFumble);
Assert.Null(percentileSkill.FumbleRange);
var missingOpenEndedFumbleRange = service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "1d100!+85", 0, false, rolemasterGroup.Id);
Assert.False(missingOpenEndedFumbleRange.Succeeded);
var invalidOpenEndedFumbleRange = service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "1d100!+85", 0, false, rolemasterGroup.Id, 96);
Assert.False(invalidOpenEndedFumbleRange.Succeeded);
var openEndedSkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "1d100!+85", 0, false, rolemasterGroup.Id, 5));
Assert.Equal("d100!+85", openEndedSkill.DiceRollDefinition);
Assert.Equal(0, openEndedSkill.WildDice);
Assert.False(openEndedSkill.AllowFumble);
Assert.Equal(5, openEndedSkill.FumbleRange);
}
} }

View File

@@ -87,11 +87,11 @@ public sealed class WorkspaceQueryServiceTests
public ServiceResult<bool> DeleteCharacter(string sessionToken, Guid characterId) => throw new NotSupportedException(); public ServiceResult<bool> DeleteCharacter(string sessionToken, Guid characterId) => throw new NotSupportedException();
public ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId) => throw new NotSupportedException(); public ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId) => throw new NotSupportedException();
public ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken) => throw new NotSupportedException(); public ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken) => throw new NotSupportedException();
public ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble) => throw new NotSupportedException(); public ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null) => throw new NotSupportedException();
public ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble) => throw new NotSupportedException(); public ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null) => throw new NotSupportedException();
public ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId) => throw new NotSupportedException(); public ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId) => throw new NotSupportedException();
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null) => throw new NotSupportedException(); public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) => throw new NotSupportedException();
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null) => throw new NotSupportedException(); public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) => throw new NotSupportedException();
public ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId) => throw new NotSupportedException(); public ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId) => throw new NotSupportedException();
public ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId) => throw new NotSupportedException(); public ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId) => throw new NotSupportedException();
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility) => throw new NotSupportedException(); public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility) => throw new NotSupportedException();

View File

@@ -9,13 +9,13 @@ internal static class SkillEndpoints
{ {
group.MapPost("/characters/{characterId:guid}/skills", (Guid characterId, CreateSkillRequest request, HttpContext context, IGameService game) => group.MapPost("/characters/{characterId:guid}/skills", (Guid characterId, CreateSkillRequest request, HttpContext context, IGameService game) =>
{ {
var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId); var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId, request.FumbleRange);
return ApiResultMapper.ToApiResult(result); return ApiResultMapper.ToApiResult(result);
}); });
group.MapPut("/skills/{skillId:guid}", (Guid skillId, UpdateSkillRequest request, HttpContext context, IGameService game) => group.MapPut("/skills/{skillId:guid}", (Guid skillId, UpdateSkillRequest request, HttpContext context, IGameService game) =>
{ {
var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId); var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId, request.FumbleRange);
return ApiResultMapper.ToApiResult(result); return ApiResultMapper.ToApiResult(result);
}); });
@@ -27,13 +27,13 @@ internal static class SkillEndpoints
group.MapPost("/characters/{characterId:guid}/skill-groups", (Guid characterId, CreateSkillGroupRequest request, HttpContext context, IGameService game) => group.MapPost("/characters/{characterId:guid}/skill-groups", (Guid characterId, CreateSkillGroupRequest request, HttpContext context, IGameService game) =>
{ {
var result = game.CreateSkillGroup(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble); var result = game.CreateSkillGroup(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.FumbleRange);
return ApiResultMapper.ToApiResult(result); return ApiResultMapper.ToApiResult(result);
}); });
group.MapPut("/skill-groups/{skillGroupId:guid}", (Guid skillGroupId, UpdateSkillGroupRequest request, HttpContext context, IGameService game) => group.MapPut("/skill-groups/{skillGroupId:guid}", (Guid skillGroupId, UpdateSkillGroupRequest request, HttpContext context, IGameService game) =>
{ {
var result = game.UpdateSkillGroup(context.GetRequiredSessionToken(), skillGroupId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble); var result = game.UpdateSkillGroup(context.GetRequiredSessionToken(), skillGroupId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.FumbleRange);
return ApiResultMapper.ToApiResult(result); return ApiResultMapper.ToApiResult(result);
}); });

View File

@@ -7,7 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="@BaseHref"/> <base href="@BaseHref"/>
<title>RpgRoller</title> <title>RpgRoller</title>
<link rel="stylesheet" href="styles.css"/> <link rel="stylesheet" href="@Assets["styles.css"]"/>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">

View File

@@ -42,18 +42,22 @@ public sealed class CharacterFormModel
public sealed class SkillFormModel public sealed class SkillFormModel
{ {
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string RulesetId { get; set; } = string.Empty;
public string DiceRollDefinition { get; set; } = string.Empty; public string DiceRollDefinition { get; set; } = string.Empty;
public string SkillGroupId { get; set; } = string.Empty; public string SkillGroupId { get; set; } = string.Empty;
public int WildDice { get; set; } public int WildDice { get; set; }
public bool AllowFumble { get; set; } public bool AllowFumble { get; set; }
public int? FumbleRange { get; set; }
} }
public sealed class SkillGroupFormModel public sealed class SkillGroupFormModel
{ {
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string RulesetId { get; set; } = string.Empty;
public string DiceRollDefinition { get; set; } = string.Empty; public string DiceRollDefinition { get; set; } = string.Empty;
public int WildDice { get; set; } public int WildDice { get; set; }
public bool AllowFumble { get; set; } public bool AllowFumble { get; set; }
public int? FumbleRange { get; set; }
} }
public enum HomeViewMode public enum HomeViewMode

View File

@@ -152,13 +152,14 @@
} }
<label for="skill-group-expression">Prototype expression</label> <label for="skill-group-expression">Prototype expression</label>
<input id="skill-group-expression" @bind="SkillGroupState.Model.DiceRollDefinition" @bind:event="oninput"/> <input id="skill-group-expression" value="@SkillGroupState.Model.DiceRollDefinition" @oninput="OnSkillGroupExpressionChanged"/>
<p class="field-help">@SkillGroupExpressionHelpText</p>
@if (SkillGroupState.Errors.TryGetValue("diceRollDefinition", out var expressionError)) @if (SkillGroupState.Errors.TryGetValue("diceRollDefinition", out var expressionError))
{ {
<p class="field-error">@expressionError</p> <p class="field-error">@expressionError</p>
} }
@if (IsD6) @if (IsD6Ruleset)
{ {
<label for="skill-group-wild-dice">Prototype wild dice</label> <label for="skill-group-wild-dice">Prototype wild dice</label>
<input id="skill-group-wild-dice" type="number" min="1" step="1" @bind="SkillGroupState.Model.WildDice"/> <input id="skill-group-wild-dice" type="number" min="1" step="1" @bind="SkillGroupState.Model.WildDice"/>
@@ -170,6 +171,19 @@
<label for="skill-group-allow-fumble">Prototype allow fumble</label> <label for="skill-group-allow-fumble">Prototype allow fumble</label>
<input id="skill-group-allow-fumble" type="checkbox" @bind="SkillGroupState.Model.AllowFumble"/> <input id="skill-group-allow-fumble" type="checkbox" @bind="SkillGroupState.Model.AllowFumble"/>
} }
else if (IsRolemasterRuleset)
{
@if (IsSkillGroupRolemasterOpenEnded)
{
<label for="skill-group-fumble-range">Prototype fumble range</label>
<input id="skill-group-fumble-range" type="number" min="0" max="95" step="1" @bind="SkillGroupState.Model.FumbleRange"/>
<p class="field-help">Used only for open-ended percentile skills created from this group.</p>
@if (SkillGroupState.Errors.TryGetValue("fumbleRange", out var fumbleRangeError))
{
<p class="field-error">@fumbleRangeError</p>
}
}
}
@if (SkillGroupState.Errors.TryGetValue("character", out var characterError)) @if (SkillGroupState.Errors.TryGetValue("character", out var characterError))
{ {
@@ -192,7 +206,7 @@
<SkillFormModal <SkillFormModal
Visible="ShowCreateSkillModal" Visible="ShowCreateSkillModal"
AutoFocusName="true" AutoFocusName="true"
IsD6="IsD6" RulesetId="@SelectedCampaignRulesetId"
Title="Create Skill" Title="Create Skill"
SubmitLabel="Create Skill" SubmitLabel="Create Skill"
NameInputId="skill-create-name" NameInputId="skill-create-name"
@@ -200,6 +214,7 @@
SkillGroupInputId="skill-create-group" SkillGroupInputId="skill-create-group"
WildDiceInputId="skill-create-wild-dice" WildDiceInputId="skill-create-wild-dice"
AllowFumbleInputId="skill-create-allow-fumble" AllowFumbleInputId="skill-create-allow-fumble"
FumbleRangeInputId="skill-create-fumble-range"
InitialModel="CreateSkillInitialModel" InitialModel="CreateSkillInitialModel"
FormVersion="CreateSkillFormVersion" FormVersion="CreateSkillFormVersion"
SelectedCharacterId="SelectedCharacterId" SelectedCharacterId="SelectedCharacterId"
@@ -211,7 +226,7 @@
<SkillFormModal <SkillFormModal
Visible="ShowEditSkillModal" Visible="ShowEditSkillModal"
IsD6="IsD6" RulesetId="@SelectedCampaignRulesetId"
Title="Edit Skill" Title="Edit Skill"
SubmitLabel="Save Skill" SubmitLabel="Save Skill"
NameInputId="skill-edit-name" NameInputId="skill-edit-name"
@@ -219,6 +234,7 @@
SkillGroupInputId="skill-edit-group" SkillGroupInputId="skill-edit-group"
WildDiceInputId="skill-edit-wild-dice" WildDiceInputId="skill-edit-wild-dice"
AllowFumbleInputId="skill-edit-allow-fumble" AllowFumbleInputId="skill-edit-allow-fumble"
FumbleRangeInputId="skill-edit-fumble-range"
InitialModel="EditSkillInitialModel" InitialModel="EditSkillInitialModel"
FormVersion="EditSkillFormVersion" FormVersion="EditSkillFormVersion"
SelectedCharacterId="SelectedCharacterId" SelectedCharacterId="SelectedCharacterId"

View File

@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts; using RpgRoller.Contracts;
@@ -16,12 +17,17 @@ public partial class CharacterPanel
CreateSkillInitialModel = new() CreateSkillInitialModel = new()
{ {
Name = string.Empty, Name = string.Empty,
RulesetId = SelectedCampaignRulesetId,
DiceRollDefinition = selectedGroup?.DiceRollDefinition ?? string.Empty, DiceRollDefinition = selectedGroup?.DiceRollDefinition ?? string.Empty,
SkillGroupId = selectedGroup?.Id.ToString() ?? string.Empty, SkillGroupId = selectedGroup?.Id.ToString() ?? string.Empty,
WildDice = selectedGroup?.WildDice ?? (IsD6 ? 1 : 0), WildDice = selectedGroup?.WildDice ?? (IsD6Ruleset ? 1 : 0),
AllowFumble = selectedGroup?.AllowFumble ?? IsD6 AllowFumble = selectedGroup?.AllowFumble ?? IsD6Ruleset,
FumbleRange = selectedGroup?.FumbleRange
}; };
if (IsRolemasterRuleset && string.IsNullOrWhiteSpace(CreateSkillInitialModel.DiceRollDefinition))
CreateSkillInitialModel.DiceRollDefinition = "d100";
CreateSkillFormVersion++; CreateSkillFormVersion++;
ShowCreateSkillModal = true; ShowCreateSkillModal = true;
} }
@@ -32,10 +38,12 @@ public partial class CharacterPanel
EditSkillInitialModel = new() EditSkillInitialModel = new()
{ {
Name = skill.Name, Name = skill.Name,
RulesetId = SelectedCampaignRulesetId,
DiceRollDefinition = skill.DiceRollDefinition, DiceRollDefinition = skill.DiceRollDefinition,
SkillGroupId = skill.SkillGroupId?.ToString() ?? string.Empty, SkillGroupId = skill.SkillGroupId?.ToString() ?? string.Empty,
WildDice = skill.WildDice, WildDice = skill.WildDice,
AllowFumble = skill.AllowFumble AllowFumble = skill.AllowFumble,
FumbleRange = skill.FumbleRange
}; };
EditSkillFormVersion++; EditSkillFormVersion++;
@@ -96,9 +104,13 @@ public partial class CharacterPanel
private void OpenCreateSkillGroupModal() private void OpenCreateSkillGroupModal()
{ {
SkillGroupState.Model.Name = string.Empty; SkillGroupState.Model.Name = string.Empty;
SkillGroupState.Model.RulesetId = SelectedCampaignRulesetId;
SkillGroupState.Model.DiceRollDefinition = string.Empty; SkillGroupState.Model.DiceRollDefinition = string.Empty;
SkillGroupState.Model.WildDice = IsD6 ? 1 : 0; SkillGroupState.Model.WildDice = IsD6Ruleset ? 1 : 0;
SkillGroupState.Model.AllowFumble = IsD6; SkillGroupState.Model.AllowFumble = IsD6Ruleset;
SkillGroupState.Model.FumbleRange = null;
if (IsRolemasterRuleset)
SkillGroupState.Model.DiceRollDefinition = "d100";
SkillGroupState.ResetValidation(); SkillGroupState.ResetValidation();
ShowCreateSkillGroupModal = true; ShowCreateSkillGroupModal = true;
} }
@@ -107,9 +119,12 @@ public partial class CharacterPanel
{ {
EditingSkillGroupId = skillGroup.Id; EditingSkillGroupId = skillGroup.Id;
SkillGroupState.Model.Name = skillGroup.Name; SkillGroupState.Model.Name = skillGroup.Name;
SkillGroupState.Model.RulesetId = SelectedCampaignRulesetId;
SkillGroupState.Model.DiceRollDefinition = skillGroup.DiceRollDefinition; SkillGroupState.Model.DiceRollDefinition = skillGroup.DiceRollDefinition;
SkillGroupState.Model.WildDice = skillGroup.WildDice; SkillGroupState.Model.WildDice = skillGroup.WildDice;
SkillGroupState.Model.AllowFumble = skillGroup.AllowFumble; SkillGroupState.Model.AllowFumble = skillGroup.AllowFumble;
SkillGroupState.Model.FumbleRange = skillGroup.FumbleRange;
NormalizeSkillGroupFumbleRange();
SkillGroupState.ResetValidation(); SkillGroupState.ResetValidation();
ShowEditSkillGroupModal = true; ShowEditSkillGroupModal = true;
} }
@@ -132,9 +147,25 @@ public partial class CharacterPanel
if (string.IsNullOrWhiteSpace(SkillGroupState.Model.DiceRollDefinition)) if (string.IsNullOrWhiteSpace(SkillGroupState.Model.DiceRollDefinition))
SkillGroupState.Errors["diceRollDefinition"] = "Expression is required."; SkillGroupState.Errors["diceRollDefinition"] = "Expression is required.";
if (IsD6 && SkillGroupState.Model.WildDice < 1) if (IsD6Ruleset && SkillGroupState.Model.WildDice < 1)
SkillGroupState.Errors["wildDice"] = "D6 groups require at least one wild die."; SkillGroupState.Errors["wildDice"] = "D6 groups require at least one wild die.";
if (IsRolemasterRuleset)
{
if (IsSkillGroupRolemasterOpenEnded && !SkillGroupState.Model.FumbleRange.HasValue)
SkillGroupState.Errors["fumbleRange"] = "Open-ended Rolemaster groups require a fumble range.";
}
else
{
SkillGroupState.Model.FumbleRange = null;
}
if (!IsD6Ruleset)
{
SkillGroupState.Model.WildDice = 0;
SkillGroupState.Model.AllowFumble = false;
}
if (!SelectedCharacterId.HasValue) if (!SelectedCharacterId.HasValue)
SkillGroupState.Errors["character"] = "Select a character first."; SkillGroupState.Errors["character"] = "Select a character first.";
@@ -155,7 +186,8 @@ public partial class CharacterPanel
SkillGroupState.Model.Name.Trim(), SkillGroupState.Model.Name.Trim(),
SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.DiceRollDefinition.Trim(),
SkillGroupState.Model.WildDice, SkillGroupState.Model.WildDice,
SkillGroupState.Model.AllowFumble)); SkillGroupState.Model.AllowFumble,
SkillGroupState.Model.FumbleRange));
CloseSkillGroupModals(); CloseSkillGroupModals();
await SkillGroupCreated.InvokeAsync(createdGroup.Id); await SkillGroupCreated.InvokeAsync(createdGroup.Id);
} }
@@ -179,9 +211,25 @@ public partial class CharacterPanel
if (string.IsNullOrWhiteSpace(SkillGroupState.Model.DiceRollDefinition)) if (string.IsNullOrWhiteSpace(SkillGroupState.Model.DiceRollDefinition))
SkillGroupState.Errors["diceRollDefinition"] = "Expression is required."; SkillGroupState.Errors["diceRollDefinition"] = "Expression is required.";
if (IsD6 && SkillGroupState.Model.WildDice < 1) if (IsD6Ruleset && SkillGroupState.Model.WildDice < 1)
SkillGroupState.Errors["wildDice"] = "D6 groups require at least one wild die."; SkillGroupState.Errors["wildDice"] = "D6 groups require at least one wild die.";
if (IsRolemasterRuleset)
{
if (IsSkillGroupRolemasterOpenEnded && !SkillGroupState.Model.FumbleRange.HasValue)
SkillGroupState.Errors["fumbleRange"] = "Open-ended Rolemaster groups require a fumble range.";
}
else
{
SkillGroupState.Model.FumbleRange = null;
}
if (!IsD6Ruleset)
{
SkillGroupState.Model.WildDice = 0;
SkillGroupState.Model.AllowFumble = false;
}
if (!EditingSkillGroupId.HasValue) if (!EditingSkillGroupId.HasValue)
SkillGroupState.Errors["group"] = "Select a skill group first."; SkillGroupState.Errors["group"] = "Select a skill group first.";
@@ -202,7 +250,8 @@ public partial class CharacterPanel
SkillGroupState.Model.Name.Trim(), SkillGroupState.Model.Name.Trim(),
SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.DiceRollDefinition.Trim(),
SkillGroupState.Model.WildDice, SkillGroupState.Model.WildDice,
SkillGroupState.Model.AllowFumble)); SkillGroupState.Model.AllowFumble,
SkillGroupState.Model.FumbleRange));
CloseSkillGroupModals(); CloseSkillGroupModals();
await SkillGroupUpdated.InvokeAsync(updatedGroup.Id); await SkillGroupUpdated.InvokeAsync(updatedGroup.Id);
} }
@@ -264,6 +313,37 @@ public partial class CharacterPanel
return string.Concat(words[0][0], words[1][0]).ToUpperInvariant(); return string.Concat(words[0][0], words[1][0]).ToUpperInvariant();
} }
private void OnSkillGroupExpressionChanged(ChangeEventArgs args)
{
SkillGroupState.Model.DiceRollDefinition = args.Value?.ToString() ?? string.Empty;
if (IsRolemasterRuleset)
NormalizeSkillGroupFumbleRange();
}
private void NormalizeSkillGroupFumbleRange()
{
if (!IsRolemasterRuleset)
{
SkillGroupState.Model.FumbleRange = null;
return;
}
if (IsSkillGroupRolemasterOpenEnded)
{
SkillGroupState.Model.FumbleRange ??= 5;
return;
}
SkillGroupState.Model.FumbleRange = null;
}
private bool IsD6Ruleset => RulesetFormHelpers.IsD6(SelectedCampaignRulesetId);
private bool IsRolemasterRuleset => RulesetFormHelpers.IsRolemaster(SelectedCampaignRulesetId);
private bool IsSkillGroupRolemasterOpenEnded => RulesetFormHelpers.IsRolemasterOpenEndedExpression(SkillGroupState.Model.DiceRollDefinition);
private string SkillGroupExpressionHelpText => IsRolemasterRuleset
? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed."
: "Enter the default expression for skills created in this group.";
private bool ShowCreateSkillModal { get; set; } private bool ShowCreateSkillModal { get; set; }
private bool ShowEditSkillModal { get; set; } private bool ShowEditSkillModal { get; set; }
private bool ShowCreateSkillGroupModal { get; set; } private bool ShowCreateSkillGroupModal { get; set; }
@@ -303,7 +383,7 @@ public partial class CharacterPanel
public IReadOnlyList<CharacterSheetSkillGroup> SelectedCharacterSkillGroups { get; set; } = []; public IReadOnlyList<CharacterSheetSkillGroup> SelectedCharacterSkillGroups { get; set; } = [];
[Parameter] [Parameter]
public bool IsD6 { get; set; } public string SelectedCampaignRulesetId { get; set; } = string.Empty;
[Parameter] [Parameter]
public string RollVisibility { get; set; } = "public"; public string RollVisibility { get; set; } = "public";

View File

@@ -3,7 +3,7 @@
<div class="roll-dice-strip" aria-label="@AriaLabel"> <div class="roll-dice-strip" aria-label="@AriaLabel">
@foreach (var die in Dice) @foreach (var die in Dice)
{ {
<span class="@RollDieCssClass(die)" title="@RollDieTitle(die)">@RollDieGlyph(die.Roll)</span> <span class="@RollDieCssClass(die)" title="@RollDieTitle(die)">@RollDieDisplay(die)</span>
} }
</div> </div>
} }

View File

@@ -21,6 +21,27 @@ public partial class RollDiceStrip
}; };
} }
private static string RollDieDisplay(RollDieResult die)
{
if (string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal) && !die.SignedContribution.HasValue)
return $"({die.Roll:00})";
if (IsRolemasterDie(die))
{
return die.Kind switch
{
RollDieKinds.RolemasterOpenEndedHigh => $"+{die.Roll:00}",
RollDieKinds.RolemasterOpenEndedLowSubtract => $"-{die.Roll:00}",
_ => die.Roll.ToString("00")
};
}
return die.Kind switch
{
_ => RollDieGlyph(die.Roll)
};
}
private static string RollDieCssClass(RollDieResult die) private static string RollDieCssClass(RollDieResult die)
{ {
var classes = new List<string> { "die-chip" }; var classes = new List<string> { "die-chip" };
@@ -39,12 +60,39 @@ public partial class RollDiceStrip
if (die.Added) if (die.Added)
classes.Add("added"); classes.Add("added");
switch (die.Kind)
{
case RollDieKinds.RolemasterStandard:
classes.Add("rolemaster-standard");
break;
case RollDieKinds.RolemasterOpenEndedInitial:
classes.Add("rolemaster-open-ended-initial");
break;
case RollDieKinds.RolemasterOpenEndedHigh:
classes.Add("rolemaster-open-ended-high");
break;
case RollDieKinds.RolemasterOpenEndedLowSubtract:
classes.Add("rolemaster-open-ended-low-subtract");
break;
}
return string.Join(" ", classes); return string.Join(" ", classes);
} }
private static bool IsRolemasterDie(RollDieResult die)
{
return die.Kind is RollDieKinds.RolemasterStandard or
RollDieKinds.RolemasterOpenEndedInitial or
RollDieKinds.RolemasterOpenEndedHigh or
RollDieKinds.RolemasterOpenEndedLowSubtract;
}
private static string RollDieTitle(RollDieResult die) private static string RollDieTitle(RollDieResult die)
{ {
var labels = new List<string> { $"Roll {die.Roll}" }; var labels = new List<string> { $"Roll {die.Roll}" };
if (die.Sequence.HasValue)
labels.Add($"step {die.Sequence.Value}");
if (die.Wild) if (die.Wild)
labels.Add("wild"); labels.Add("wild");
@@ -60,6 +108,22 @@ public partial class RollDiceStrip
if (die.Added) if (die.Added)
labels.Add("added"); labels.Add("added");
switch (die.Kind)
{
case RollDieKinds.RolemasterStandard:
labels.Add("Rolemaster roll");
break;
case RollDieKinds.RolemasterOpenEndedInitial:
labels.Add(die.SignedContribution.HasValue ? "Rolemaster open-ended initial" : "Rolemaster low-end trigger (ignored in total)");
break;
case RollDieKinds.RolemasterOpenEndedHigh:
labels.Add($"Rolemaster high follow-up (+{die.Roll})");
break;
case RollDieKinds.RolemasterOpenEndedLowSubtract:
labels.Add($"Rolemaster low-end subtraction (-{die.Roll})");
break;
}
return string.Join(", ", labels); return string.Join(", ", labels);
} }

View File

@@ -0,0 +1,62 @@
using System.Diagnostics.CodeAnalysis;
using RpgRoller.Domain;
using RpgRoller.Services;
namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage]
internal static class RulesetFormHelpers
{
internal static class RulesetIds
{
public const string D6 = "d6";
public const string Dnd5e = "dnd5e";
public const string Rolemaster = "rolemaster";
}
public static bool IsD6(string? rulesetId)
{
return string.Equals(rulesetId, RulesetIds.D6, StringComparison.OrdinalIgnoreCase);
}
public static bool IsRolemaster(string? rulesetId)
{
return string.Equals(rulesetId, RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase);
}
public static bool IsRolemasterOpenEndedExpression(string? expression)
{
var parseResult = TryParseRolemasterExpression(expression);
return parseResult.Succeeded &&
parseResult.Value is not null &&
parseResult.Value.Kind == DiceExpressionKind.RolemasterOpenEndedPercentile;
}
public static string DescribeRolemasterExpression(string expression, int? fumbleRange)
{
var parseResult = TryParseRolemasterExpression(expression);
if (!parseResult.Succeeded || parseResult.Value is null)
return expression;
return parseResult.Value.Kind switch
{
DiceExpressionKind.RolemasterOpenEndedPercentile => fumbleRange.HasValue
? $"Open-ended percentile: {parseResult.Value.Canonical}, fumble <= {fumbleRange.Value}"
: $"Open-ended percentile: {parseResult.Value.Canonical}",
_ => $"Rolemaster: {parseResult.Value.Canonical}"
};
}
public static string RolemasterExampleText()
{
return "Examples: d10, 15d10, d100-15, d100!+85";
}
private static ServiceResult<DiceExpression> TryParseRolemasterExpression(string? expression)
{
if (string.IsNullOrWhiteSpace(expression))
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Expression is required.");
return DiceRules.ParseExpression(RulesetKind.Rolemaster, expression);
}
}

View File

@@ -15,7 +15,8 @@
<p class="field-error">@skillNameError</p> <p class="field-error">@skillNameError</p>
} }
<label for="@ExpressionInputId">Expression</label> <label for="@ExpressionInputId">Expression</label>
<input id="@ExpressionInputId" @bind="FormState.Model.DiceRollDefinition" @bind:event="oninput"/> <input id="@ExpressionInputId" value="@FormState.Model.DiceRollDefinition" @oninput="OnExpressionChanged"/>
<p class="field-help">@ExpressionHelpText</p>
@if (FormState.Errors.TryGetValue("diceRollDefinition", out var expressionError)) @if (FormState.Errors.TryGetValue("diceRollDefinition", out var expressionError))
{ {
<p class="field-error">@expressionError</p> <p class="field-error">@expressionError</p>
@@ -32,7 +33,7 @@
{ {
<p class="field-error">@skillGroupError</p> <p class="field-error">@skillGroupError</p>
} }
@if (IsD6) @if (IsD6Ruleset)
{ {
<label for="@WildDiceInputId">Wild dice</label> <label for="@WildDiceInputId">Wild dice</label>
<input id="@WildDiceInputId" type="number" min="1" step="1" @bind="FormState.Model.WildDice"/> <input id="@WildDiceInputId" type="number" min="1" step="1" @bind="FormState.Model.WildDice"/>
@@ -44,6 +45,19 @@
<label for="@AllowFumbleInputId">Allow fumble</label> <label for="@AllowFumbleInputId">Allow fumble</label>
<input id="@AllowFumbleInputId" type="checkbox" @bind="FormState.Model.AllowFumble"/> <input id="@AllowFumbleInputId" type="checkbox" @bind="FormState.Model.AllowFumble"/>
} }
else if (IsRolemasterRuleset)
{
@if (IsRolemasterOpenEndedSelected)
{
<label for="@FumbleRangeInputId">Fumble range</label>
<input id="@FumbleRangeInputId" type="number" min="0" max="95" step="1" @bind="FormState.Model.FumbleRange"/>
<p class="field-help">Used only for low-end open-ended rolls. Allowed range: 0 to 95.</p>
@if (FormState.Errors.TryGetValue("fumbleRange", out var fumbleRangeError))
{
<p class="field-error">@fumbleRangeError</p>
}
}
}
<div class="inline-actions"> <div class="inline-actions">
<button type="submit" disabled="@(IsMutating || IsSubmitting)">@SubmitLabel</button> <button type="submit" disabled="@(IsMutating || IsSubmitting)">@SubmitLabel</button>
<button type="button" class="ghost" @onclick="CancelRequested">Cancel</button> <button type="button" class="ghost" @onclick="CancelRequested">Cancel</button>

View File

@@ -14,10 +14,13 @@ public partial class SkillFormModal
return; return;
FormState.Model.Name = InitialModel.Name; FormState.Model.Name = InitialModel.Name;
FormState.Model.RulesetId = RulesetId;
FormState.Model.DiceRollDefinition = InitialModel.DiceRollDefinition; FormState.Model.DiceRollDefinition = InitialModel.DiceRollDefinition;
FormState.Model.SkillGroupId = InitialModel.SkillGroupId; FormState.Model.SkillGroupId = InitialModel.SkillGroupId;
FormState.Model.WildDice = InitialModel.WildDice; FormState.Model.WildDice = InitialModel.WildDice;
FormState.Model.AllowFumble = InitialModel.AllowFumble; FormState.Model.AllowFumble = InitialModel.AllowFumble;
FormState.Model.FumbleRange = InitialModel.FumbleRange;
SynchronizeRulesetSpecificFields();
FormState.ResetValidation(); FormState.ResetValidation();
AppliedFormVersion = FormVersion; AppliedFormVersion = FormVersion;
PendingNameFocus = AutoFocusName; PendingNameFocus = AutoFocusName;
@@ -42,9 +45,25 @@ public partial class SkillFormModal
if (string.IsNullOrWhiteSpace(FormState.Model.DiceRollDefinition)) if (string.IsNullOrWhiteSpace(FormState.Model.DiceRollDefinition))
FormState.Errors["diceRollDefinition"] = "Expression is required."; FormState.Errors["diceRollDefinition"] = "Expression is required.";
if (IsD6 && FormState.Model.WildDice < 1) if (IsD6Ruleset && FormState.Model.WildDice < 1)
FormState.Errors["wildDice"] = "D6 skills require at least one wild die."; FormState.Errors["wildDice"] = "D6 skills require at least one wild die.";
if (IsRolemasterRuleset)
{
if (IsRolemasterOpenEndedSelected && !FormState.Model.FumbleRange.HasValue)
FormState.Errors["fumbleRange"] = "Open-ended Rolemaster skills require a fumble range.";
}
else
{
FormState.Model.FumbleRange = null;
}
if (!IsD6Ruleset)
{
FormState.Model.WildDice = 0;
FormState.Model.AllowFumble = false;
}
Guid? skillGroupId = null; Guid? skillGroupId = null;
if (!string.IsNullOrWhiteSpace(FormState.Model.SkillGroupId)) if (!string.IsNullOrWhiteSpace(FormState.Model.SkillGroupId))
{ {
@@ -66,7 +85,7 @@ public partial class SkillFormModal
SkillSummary skill; SkillSummary skill;
if (EditingSkillId.HasValue) if (EditingSkillId.HasValue)
{ {
skill = await ApiClient.RequestAsync<SkillSummary>("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId)); skill = await ApiClient.RequestAsync<SkillSummary>("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId, FormState.Model.FumbleRange));
} }
else else
{ {
@@ -76,7 +95,7 @@ public partial class SkillFormModal
return; return;
} }
skill = await ApiClient.RequestAsync<SkillSummary>("POST", $"/api/characters/{SelectedCharacterId.Value}/skills", new CreateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId)); skill = await ApiClient.RequestAsync<SkillSummary>("POST", $"/api/characters/{SelectedCharacterId.Value}/skills", new CreateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId, FormState.Model.FumbleRange));
} }
await SkillSaved.InvokeAsync(skill.Id); await SkillSaved.InvokeAsync(skill.Id);
@@ -91,6 +110,45 @@ public partial class SkillFormModal
} }
} }
private void OnExpressionChanged(ChangeEventArgs args)
{
FormState.Model.DiceRollDefinition = args.Value?.ToString() ?? string.Empty;
if (IsRolemasterRuleset)
NormalizeRolemasterFumbleRange();
}
private bool IsD6Ruleset => RulesetFormHelpers.IsD6(RulesetId);
private bool IsRolemasterRuleset => RulesetFormHelpers.IsRolemaster(RulesetId);
private bool IsRolemasterOpenEndedSelected => RulesetFormHelpers.IsRolemasterOpenEndedExpression(FormState.Model.DiceRollDefinition);
private string ExpressionHelpText => IsRolemasterRuleset
? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed."
: "Enter the dice expression used for this skill.";
private void SynchronizeRulesetSpecificFields()
{
if (!IsRolemasterRuleset)
return;
NormalizeRolemasterFumbleRange();
}
private void NormalizeRolemasterFumbleRange()
{
if (!IsRolemasterRuleset)
{
FormState.Model.FumbleRange = null;
return;
}
if (IsRolemasterOpenEndedSelected)
{
FormState.Model.FumbleRange ??= 5;
return;
}
FormState.Model.FumbleRange = null;
}
[Inject] [Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!; private RpgRollerApiClient ApiClient { get; set; } = null!;
@@ -104,7 +162,7 @@ public partial class SkillFormModal
public bool Visible { get; set; } public bool Visible { get; set; }
[Parameter] [Parameter]
public bool IsD6 { get; set; } public string RulesetId { get; set; } = string.Empty;
[Parameter] [Parameter]
public string Title { get; set; } = "Skill"; public string Title { get; set; } = "Skill";
@@ -127,6 +185,9 @@ public partial class SkillFormModal
[Parameter] [Parameter]
public string AllowFumbleInputId { get; set; } = "skill-fumble"; public string AllowFumbleInputId { get; set; } = "skill-fumble";
[Parameter]
public string FumbleRangeInputId { get; set; } = "skill-fumble-range";
[Parameter] [Parameter]
public SkillFormModel InitialModel { get; set; } = new(); public SkillFormModel InitialModel { get; set; } = new();

View File

@@ -39,7 +39,7 @@
IsMutating="IsMutating" IsMutating="IsMutating"
SelectedCharacterSkills="PlaySelectedCharacterSkills" SelectedCharacterSkills="PlaySelectedCharacterSkills"
SelectedCharacterSkillGroups="PlaySelectedCharacterSkillGroups" SelectedCharacterSkillGroups="PlaySelectedCharacterSkillGroups"
IsD6="IsSelectedCampaignD6" SelectedCampaignRulesetId="@(PlaySelectedCampaign?.RulesetId ?? string.Empty)"
RollVisibility="RollVisibility" RollVisibility="RollVisibility"
RollVisibilityChanged="OnRollVisibilityChanged" RollVisibilityChanged="OnRollVisibilityChanged"
OwnerLabel="OwnerLabel" OwnerLabel="OwnerLabel"

View File

@@ -920,7 +920,12 @@ public partial class Workspace : IAsyncDisposable
private string SkillDefinitionLabel(CharacterSheetSkill skill) private string SkillDefinitionLabel(CharacterSheetSkill skill)
{ {
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
{
if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase))
return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange);
return skill.DiceRollDefinition; return skill.DiceRollDefinition;
}
var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off"; var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off";
return $"{skill.DiceRollDefinition}, wild {skill.WildDice}, {fumbleLabel}"; return $"{skill.DiceRollDefinition}, wild {skill.WildDice}, {fumbleLabel}";

View File

@@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
namespace RpgRoller.Contracts; namespace RpgRoller.Contracts;
public sealed record HealthResponse(string Status); public sealed record HealthResponse(string Status);
@@ -34,27 +36,69 @@ public sealed record UpdateCharacterRequest(string Name, Guid? CampaignId, strin
public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid? CampaignId, string OwnerDisplayName); public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid? CampaignId, string OwnerDisplayName);
public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null); public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null, int? FumbleRange = null);
public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null); public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null, int? FumbleRange = null);
public sealed record SkillGroupSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble); public sealed record SkillGroupSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange);
public sealed record CreateSkillGroupRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble); public sealed record CreateSkillGroupRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange = null);
public sealed record UpdateSkillGroupRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble); public sealed record UpdateSkillGroupRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange = null);
public sealed record SkillSummary(Guid Id, Guid CharacterId, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble); public sealed record SkillSummary(Guid Id, Guid CharacterId, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange);
public sealed record RollSkillRequest(string Visibility); public sealed record RollSkillRequest(string Visibility);
public sealed record RollDieResult(int Roll, bool Crit, bool Fumble, bool Wild, bool Removed, bool Added); public static class RollDieKinds
{
public const string RolemasterStandard = "rolemaster-standard";
public const string RolemasterOpenEndedInitial = "rolemaster-open-ended-initial";
public const string RolemasterOpenEndedHigh = "rolemaster-open-ended-high";
public const string RolemasterOpenEndedLowSubtract = "rolemaster-open-ended-low-subtract";
}
public sealed record RollDieResult
{
public RollDieResult()
{
}
public RollDieResult(int roll, bool crit, bool fumble, bool wild, bool removed, bool added, int? sequence = null, string? kind = null, int? signedContribution = null)
{
Roll = roll;
Crit = crit;
Fumble = fumble;
Wild = wild;
Removed = removed;
Added = added;
Sequence = sequence;
Kind = kind;
SignedContribution = signedContribution;
}
public int Roll { get; init; }
public bool Crit { get; init; }
public bool Fumble { get; init; }
public bool Wild { get; init; }
public bool Removed { get; init; }
public bool Added { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? Sequence { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Kind { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? SignedContribution { get; init; }
}
public sealed record RollResult(Guid RollId, Guid CampaignId, Guid CharacterId, Guid SkillId, Guid RollerUserId, string Visibility, int Result, string Breakdown, IReadOnlyList<RollDieResult> Dice, DateTimeOffset TimestampUtc); public sealed record RollResult(Guid RollId, Guid CampaignId, Guid CharacterId, Guid SkillId, Guid RollerUserId, string Visibility, int Result, string Breakdown, IReadOnlyList<RollDieResult> Dice, DateTimeOffset TimestampUtc);
public sealed record CharacterSheetSkillGroup(Guid Id, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble); public sealed record CharacterSheetSkillGroup(Guid Id, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange);
public sealed record CharacterSheetSkill(Guid Id, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble); public sealed record CharacterSheetSkill(Guid Id, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange);
public sealed record CharacterSheet(Guid CharacterId, CharacterSheetSkillGroup[] SkillGroups, CharacterSheetSkill[] Skills); public sealed record CharacterSheet(Guid CharacterId, CharacterSheetSkillGroup[] SkillGroups, CharacterSheetSkill[] Skills);

View File

@@ -54,6 +54,7 @@ public sealed class RpgRollerDbContext : DbContext
entity.Property(x => x.DiceRollDefinition).IsRequired().HasMaxLength(128); entity.Property(x => x.DiceRollDefinition).IsRequired().HasMaxLength(128);
entity.Property(x => x.WildDice).IsRequired(); entity.Property(x => x.WildDice).IsRequired();
entity.Property(x => x.AllowFumble).IsRequired(); entity.Property(x => x.AllowFumble).IsRequired();
entity.Property(x => x.FumbleRange).IsRequired(false);
entity.HasIndex(x => x.CharacterId); entity.HasIndex(x => x.CharacterId);
entity.HasIndex(x => x.SkillGroupId); entity.HasIndex(x => x.SkillGroupId);
}); });
@@ -65,6 +66,7 @@ public sealed class RpgRollerDbContext : DbContext
entity.Property(x => x.DiceRollDefinition).IsRequired().HasMaxLength(128); entity.Property(x => x.DiceRollDefinition).IsRequired().HasMaxLength(128);
entity.Property(x => x.WildDice).IsRequired(); entity.Property(x => x.WildDice).IsRequired();
entity.Property(x => x.AllowFumble).IsRequired(); entity.Property(x => x.AllowFumble).IsRequired();
entity.Property(x => x.FumbleRange).IsRequired(false);
entity.HasIndex(x => x.CharacterId); entity.HasIndex(x => x.CharacterId);
}); });

View File

@@ -3,7 +3,8 @@ namespace RpgRoller.Domain;
public enum RulesetKind public enum RulesetKind
{ {
D6, D6,
Dnd5e Dnd5e,
Rolemaster
} }
public enum RollVisibility public enum RollVisibility
@@ -60,6 +61,7 @@ public sealed class SkillGroup
public required string DiceRollDefinition { get; set; } public required string DiceRollDefinition { get; set; }
public required int WildDice { get; set; } public required int WildDice { get; set; }
public required bool AllowFumble { get; set; } public required bool AllowFumble { get; set; }
public int? FumbleRange { get; set; }
} }
public sealed class Skill public sealed class Skill
@@ -71,6 +73,7 @@ public sealed class Skill
public required string DiceRollDefinition { get; set; } public required string DiceRollDefinition { get; set; }
public required int WildDice { get; set; } public required int WildDice { get; set; }
public required bool AllowFumble { get; set; } public required bool AllowFumble { get; set; }
public int? FumbleRange { get; set; }
} }
public sealed class RollLogEntry public sealed class RollLogEntry
@@ -87,4 +90,10 @@ public sealed class RollLogEntry
public required DateTimeOffset TimestampUtc { get; init; } public required DateTimeOffset TimestampUtc { get; init; }
} }
public sealed record DiceExpression(int DiceCount, int Sides, int Modifier, string Canonical); public enum DiceExpressionKind
{
Standard,
RolemasterOpenEndedPercentile
}
public sealed record DiceExpression(int DiceCount, int Sides, int Modifier, string Canonical, DiceExpressionKind Kind = DiceExpressionKind.Standard);

View File

@@ -0,0 +1,264 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RpgRoller.Data;
#nullable disable
namespace RpgRoller.Migrations
{
[DbContext(typeof(RpgRollerDbContext))]
[Migration("20260402222501_AddRolemasterFumbleRange")]
partial class AddRolemasterFumbleRange
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("RpgRoller.Domain.Campaign", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GmUserId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Ruleset")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("Version")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("GmUserId");
b.ToTable("Campaigns");
});
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("CampaignId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("OwnerUserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("OwnerUserId");
b.ToTable("Characters");
});
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Breakdown")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid>("CampaignId")
.HasColumnType("TEXT");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("Dice")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Result")
.HasColumnType("INTEGER");
b.Property<Guid>("RollerUserId")
.HasColumnType("TEXT");
b.Property<Guid>("SkillId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("TimestampUtc")
.HasColumnType("TEXT");
b.Property<string>("Visibility")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("CharacterId");
b.HasIndex("RollerUserId");
b.HasIndex("SkillId");
b.ToTable("RollLogEntries");
});
modelBuilder.Entity("RpgRoller.Domain.Skill", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowFumble")
.HasColumnType("INTEGER");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("DiceRollDefinition")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("FumbleRange")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid?>("SkillGroupId")
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.HasIndex("SkillGroupId");
b.ToTable("Skills");
});
modelBuilder.Entity("RpgRoller.Domain.SkillGroup", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowFumble")
.HasColumnType("INTEGER");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("DiceRollDefinition")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("FumbleRange")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.ToTable("SkillGroups");
});
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ActiveCharacterId")
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Roles")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("UsernameNormalized")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UsernameNormalized")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
{
b.Property<string>("Token")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Token");
b.HasIndex("UserId");
b.ToTable("Sessions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RpgRoller.Migrations
{
/// <inheritdoc />
public partial class AddRolemasterFumbleRange : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "FumbleRange",
table: "Skills",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "FumbleRange",
table: "SkillGroups",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FumbleRange",
table: "Skills");
migrationBuilder.DropColumn(
name: "FumbleRange",
table: "SkillGroups");
}
}
}

View File

@@ -138,6 +138,9 @@ namespace RpgRoller.Migrations
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int?>("FumbleRange")
.HasColumnType("INTEGER");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(128) .HasMaxLength(128)
@@ -175,6 +178,9 @@ namespace RpgRoller.Migrations
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int?>("FumbleRange")
.HasColumnType("INTEGER");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(128) .HasMaxLength(128)

View File

@@ -34,10 +34,10 @@ if (!string.IsNullOrWhiteSpace(configuredPathBase))
} }
app.UseResponseCompression(); app.UseResponseCompression();
app.UseStaticFiles();
app.UseAntiforgery(); app.UseAntiforgery();
app.MapRpgRollerApi(); app.MapRpgRollerApi();
app.MapStaticAssets();
app.MapRazorComponents<App>().AddInteractiveServerRenderMode(); app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
app.Run(); app.Run();

View File

@@ -13,6 +13,9 @@ public static partial class DiceRules
if (string.Equals(rulesetId, "dnd5e", StringComparison.OrdinalIgnoreCase)) if (string.Equals(rulesetId, "dnd5e", StringComparison.OrdinalIgnoreCase))
return RulesetKind.Dnd5e; return RulesetKind.Dnd5e;
if (string.Equals(rulesetId, "rolemaster", StringComparison.OrdinalIgnoreCase))
return RulesetKind.Rolemaster;
return null; return null;
} }
@@ -20,9 +23,10 @@ public static partial class DiceRules
{ {
return ruleset switch return ruleset switch
{ {
RulesetKind.D6 => "d6", RulesetKind.D6 => "d6",
RulesetKind.Dnd5e => "dnd5e", RulesetKind.Dnd5e => "dnd5e",
_ => throw new ArgumentOutOfRangeException(nameof(ruleset), ruleset, "Unknown ruleset.") RulesetKind.Rolemaster => "rolemaster",
_ => throw new ArgumentOutOfRangeException(nameof(ruleset), ruleset, "Unknown ruleset.")
}; };
} }
@@ -34,9 +38,10 @@ public static partial class DiceRules
var trimmed = expression.Trim(); var trimmed = expression.Trim();
return ruleset switch return ruleset switch
{ {
RulesetKind.D6 => ParseD6(trimmed), RulesetKind.D6 => ParseD6(trimmed),
RulesetKind.Dnd5e => ParseDnd5e(trimmed), RulesetKind.Dnd5e => ParseDnd5e(trimmed),
_ => ServiceResult<DiceExpression>.Failure("invalid_ruleset", "Unknown ruleset.") RulesetKind.Rolemaster => ParseRolemaster(trimmed),
_ => ServiceResult<DiceExpression>.Failure("invalid_ruleset", "Unknown ruleset.")
}; };
} }
@@ -71,7 +76,35 @@ public static partial class DiceRules
return ServiceResult<DiceExpression>.Success(new(diceCount, sides, modifier, $"{diceCount}d{sides}{FormatModifier(modifier)}")); return ServiceResult<DiceExpression>.Success(new(diceCount, sides, modifier, $"{diceCount}d{sides}{FormatModifier(modifier)}"));
} }
private static ServiceResult<bool> ValidateDiceParts(int diceCount, int sides, int modifier) private static ServiceResult<DiceExpression> ParseRolemaster(string expression)
{
var match = RolemasterRegex().Match(expression);
if (!match.Success)
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Expected Rolemaster format like d10+4, 15d10, d100-15, or d100!+85.");
var countValue = match.Groups["count"].Value;
var diceCount = string.IsNullOrEmpty(countValue) ? 1 : int.Parse(countValue);
var sides = int.Parse(match.Groups["sides"].Value);
var modifier = ParseModifier(match.Groups["modifier"].Value);
var validation = ValidateDiceParts(diceCount, sides, modifier, -MaxModifier, MaxModifier);
if (!validation.Succeeded)
return ServiceResult<DiceExpression>.Failure(validation.Error!.Code, validation.Error.Message);
var isOpenEnded = match.Groups["openEnded"].Success;
if (isOpenEnded && (diceCount != 1 || sides != 100))
{
return ServiceResult<DiceExpression>.Failure(
"invalid_expression",
"Open-ended Rolemaster rolls must use d100! with an implicit or explicit dice count of 1.");
}
var countPrefix = diceCount == 1 ? string.Empty : diceCount.ToString();
var canonical = $"{countPrefix}d{sides}{(isOpenEnded ? "!" : string.Empty)}{FormatModifier(modifier)}";
var kind = isOpenEnded ? DiceExpressionKind.RolemasterOpenEndedPercentile : DiceExpressionKind.Standard;
return ServiceResult<DiceExpression>.Success(new(diceCount, sides, modifier, canonical, kind));
}
private static ServiceResult<bool> ValidateDiceParts(int diceCount, int sides, int modifier, int minModifier = 0, int maxModifier = MaxModifier)
{ {
if (diceCount < 1 || diceCount > MaxDiceCount) if (diceCount < 1 || diceCount > MaxDiceCount)
return ServiceResult<bool>.Failure("invalid_expression", $"Dice count must be between 1 and {MaxDiceCount}."); return ServiceResult<bool>.Failure("invalid_expression", $"Dice count must be between 1 and {MaxDiceCount}.");
@@ -79,8 +112,8 @@ public static partial class DiceRules
if (sides < 2 || sides > MaxSides) if (sides < 2 || sides > MaxSides)
return ServiceResult<bool>.Failure("invalid_expression", $"Dice sides must be between 2 and {MaxSides}."); return ServiceResult<bool>.Failure("invalid_expression", $"Dice sides must be between 2 and {MaxSides}.");
if (modifier < 0 || modifier > MaxModifier) if (modifier < minModifier || modifier > maxModifier)
return ServiceResult<bool>.Failure("invalid_expression", $"Modifier must be between 0 and {MaxModifier}."); return ServiceResult<bool>.Failure("invalid_expression", $"Modifier must be between {minModifier} and {maxModifier}.");
return ServiceResult<bool>.Success(true); return ServiceResult<bool>.Success(true);
} }
@@ -92,7 +125,12 @@ public static partial class DiceRules
private static string FormatModifier(int modifier) private static string FormatModifier(int modifier)
{ {
return modifier > 0 ? $"+{modifier}" : string.Empty; return modifier switch
{
> 0 => $"+{modifier}",
< 0 => modifier.ToString(),
_ => string.Empty
};
} }
[GeneratedRegex("^(?<count>\\d+)D(?:\\+(?<modifier>\\d+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)] [GeneratedRegex("^(?<count>\\d+)D(?:\\+(?<modifier>\\d+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
@@ -101,6 +139,9 @@ public static partial class DiceRules
[GeneratedRegex("^(?<count>\\d+)d(?<sides>\\d+)(?:\\+(?<modifier>\\d+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)] [GeneratedRegex("^(?<count>\\d+)d(?<sides>\\d+)(?:\\+(?<modifier>\\d+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
private static partial Regex Dnd5eRegex(); private static partial Regex Dnd5eRegex();
[GeneratedRegex("^(?<count>\\d+)?d(?<sides>\\d+)(?<openEnded>!)?(?<modifier>[+-]\\d+)?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
private static partial Regex RolemasterRegex();
private const int MaxDiceCount = 50; private const int MaxDiceCount = 50;
private const int MaxSides = 1000; private const int MaxSides = 1000;
private const int MaxModifier = 1000; private const int MaxModifier = 1000;
@@ -108,6 +149,7 @@ public static partial class DiceRules
public static readonly IReadOnlyList<(RulesetKind Kind, string Id, string Name, string DiceSyntax)> SupportedRulesets = public static readonly IReadOnlyList<(RulesetKind Kind, string Id, string Name, string DiceSyntax)> SupportedRulesets =
[ [
(RulesetKind.D6, "d6", "D6 System", "countD(+modifier), e.g. 5D+4"), (RulesetKind.D6, "d6", "D6 System", "countD(+modifier), e.g. 5D+4"),
(RulesetKind.Dnd5e, "dnd5e", "D&D 5e", "countdSides(+modifier), e.g. 2d12+2") (RulesetKind.Dnd5e, "dnd5e", "D&D 5e", "countdSides(+modifier), e.g. 2d12+2"),
(RulesetKind.Rolemaster, "rolemaster", "Rolemaster", "countdSides(+/-modifier), e.g. d10, 15d10, d100-15, d100!+85")
]; ];
} }

View File

@@ -496,7 +496,7 @@ public sealed class GameService : IGameService
} }
} }
public ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble) public ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillGroupSummary>.Failure("invalid_skill_group_name", "Skill group name is required."); return ServiceResult<SkillGroupSummary>.Failure("invalid_skill_group_name", "Skill group name is required.");
@@ -516,7 +516,7 @@ public sealed class GameService : IGameService
if (!CanEditCharacterLocked(user.Id, character, campaign)) if (!CanEditCharacterLocked(user.Id, character, campaign))
return ServiceResult<SkillGroupSummary>.Failure("forbidden", "Only the owner or GM can manage skill groups."); return ServiceResult<SkillGroupSummary>.Failure("forbidden", "Only the owner or GM can manage skill groups.");
var prototypeValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble); var prototypeValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange);
if (!prototypeValidation.Succeeded) if (!prototypeValidation.Succeeded)
return ServiceResult<SkillGroupSummary>.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message); return ServiceResult<SkillGroupSummary>.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message);
@@ -527,7 +527,8 @@ public sealed class GameService : IGameService
Name = name.Trim(), Name = name.Trim(),
DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression, DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression,
WildDice = prototypeValidation.Value.WildDice, WildDice = prototypeValidation.Value.WildDice,
AllowFumble = prototypeValidation.Value.AllowFumble AllowFumble = prototypeValidation.Value.AllowFumble,
FumbleRange = prototypeValidation.Value.FumbleRange
}; };
m_SkillGroupsById[group.Id] = group; m_SkillGroupsById[group.Id] = group;
@@ -538,7 +539,7 @@ public sealed class GameService : IGameService
} }
} }
public ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble) public ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillGroupSummary>.Failure("invalid_skill_group_name", "Skill group name is required."); return ServiceResult<SkillGroupSummary>.Failure("invalid_skill_group_name", "Skill group name is required.");
@@ -559,7 +560,7 @@ public sealed class GameService : IGameService
if (!CanEditCharacterLocked(user.Id, character, campaign)) if (!CanEditCharacterLocked(user.Id, character, campaign))
return ServiceResult<SkillGroupSummary>.Failure("forbidden", "Only the owner or GM can manage skill groups."); return ServiceResult<SkillGroupSummary>.Failure("forbidden", "Only the owner or GM can manage skill groups.");
var prototypeValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble); var prototypeValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange);
if (!prototypeValidation.Succeeded) if (!prototypeValidation.Succeeded)
return ServiceResult<SkillGroupSummary>.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message); return ServiceResult<SkillGroupSummary>.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message);
@@ -567,6 +568,7 @@ public sealed class GameService : IGameService
group.DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression; group.DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression;
group.WildDice = prototypeValidation.Value.WildDice; group.WildDice = prototypeValidation.Value.WildDice;
group.AllowFumble = prototypeValidation.Value.AllowFumble; group.AllowFumble = prototypeValidation.Value.AllowFumble;
group.FumbleRange = prototypeValidation.Value.FumbleRange;
TouchCharacterLocked(campaign.Id, character.Id); TouchCharacterLocked(campaign.Id, character.Id);
PersistStateLocked(); PersistStateLocked();
@@ -603,7 +605,7 @@ public sealed class GameService : IGameService
} }
} }
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null) public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required."); return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
@@ -623,7 +625,7 @@ public sealed class GameService : IGameService
if (!CanEditCharacterLocked(user.Id, character, campaign)) if (!CanEditCharacterLocked(user.Id, character, campaign))
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills."); return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
var skillValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble); var skillValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange);
if (!skillValidation.Succeeded) if (!skillValidation.Succeeded)
return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message); return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message);
@@ -639,7 +641,8 @@ public sealed class GameService : IGameService
Name = name.Trim(), Name = name.Trim(),
DiceRollDefinition = skillValidation.Value!.CanonicalExpression, DiceRollDefinition = skillValidation.Value!.CanonicalExpression,
WildDice = skillValidation.Value.WildDice, WildDice = skillValidation.Value.WildDice,
AllowFumble = skillValidation.Value.AllowFumble AllowFumble = skillValidation.Value.AllowFumble,
FumbleRange = skillValidation.Value.FumbleRange
}; };
m_SkillsById[skill.Id] = skill; m_SkillsById[skill.Id] = skill;
@@ -650,7 +653,7 @@ public sealed class GameService : IGameService
} }
} }
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null) public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required."); return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
@@ -671,7 +674,7 @@ public sealed class GameService : IGameService
if (!CanEditCharacterLocked(user.Id, character, campaign)) if (!CanEditCharacterLocked(user.Id, character, campaign))
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills."); return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
var skillValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble); var skillValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange);
if (!skillValidation.Succeeded) if (!skillValidation.Succeeded)
return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message); return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message);
@@ -683,6 +686,7 @@ public sealed class GameService : IGameService
skill.DiceRollDefinition = skillValidation.Value!.CanonicalExpression; skill.DiceRollDefinition = skillValidation.Value!.CanonicalExpression;
skill.WildDice = skillValidation.Value.WildDice; skill.WildDice = skillValidation.Value.WildDice;
skill.AllowFumble = skillValidation.Value.AllowFumble; skill.AllowFumble = skillValidation.Value.AllowFumble;
skill.FumbleRange = skillValidation.Value.FumbleRange;
skill.SkillGroupId = resolvedSkillGroupId.Value; skill.SkillGroupId = resolvedSkillGroupId.Value;
TouchCharacterLocked(campaign.Id, character.Id); TouchCharacterLocked(campaign.Id, character.Id);
@@ -876,38 +880,81 @@ public sealed class GameService : IGameService
} }
} }
private static ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble)> ValidateSkillDefinition(RulesetKind ruleset, string diceRollDefinition, int wildDice, bool allowFumble) private static ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)> ValidateSkillDefinition(RulesetKind ruleset, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange)
{ {
var expressionValidation = DiceRules.ParseExpression(ruleset, diceRollDefinition); var expressionValidation = DiceRules.ParseExpression(ruleset, diceRollDefinition);
if (!expressionValidation.Succeeded) if (!expressionValidation.Succeeded)
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble)>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message); return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
var optionsValidation = ValidateSkillOptions(ruleset, wildDice, allowFumble); var optionsValidation = ValidateSkillOptions(ruleset, expressionValidation.Value!, wildDice, allowFumble, fumbleRange);
if (!optionsValidation.Succeeded) if (!optionsValidation.Succeeded)
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble)>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message); return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble)>.Success((expressionValidation.Value!.Canonical, optionsValidation.Value!.WildDice, optionsValidation.Value.AllowFumble)); return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Success((expressionValidation.Value!.Canonical, optionsValidation.Value!.WildDice, optionsValidation.Value.AllowFumble, optionsValidation.Value.FumbleRange));
} }
private static ServiceResult<(int WildDice, bool AllowFumble)> ValidateSkillOptions(RulesetKind ruleset, int wildDice, bool allowFumble) private static ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)> ValidateSkillOptions(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange)
{ {
if (wildDice < 0 || wildDice > 50) if (wildDice < 0 || wildDice > 50)
return ServiceResult<(int WildDice, bool AllowFumble)>.Failure("invalid_wild_dice", "Wild dice must be between 0 and 50."); return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_wild_dice", "Wild dice must be between 0 and 50.");
if (ruleset == RulesetKind.D6) if (ruleset == RulesetKind.D6)
{ {
if (wildDice < 1) if (wildDice < 1)
return ServiceResult<(int WildDice, bool AllowFumble)>.Failure("invalid_wild_dice", "D6 skills require at least one wild die."); return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_wild_dice", "D6 skills require at least one wild die.");
return ServiceResult<(int WildDice, bool AllowFumble)>.Success((wildDice, allowFumble)); if (fumbleRange.HasValue)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Fumble range is only supported for Rolemaster open-ended percentile skills.");
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Success((wildDice, allowFumble, null));
} }
return ServiceResult<(int WildDice, bool AllowFumble)>.Success((0, false)); if (ruleset == RulesetKind.Rolemaster)
{
if (wildDice != 0)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_wild_dice", "Rolemaster skills do not support wild dice.");
if (allowFumble)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_allow_fumble", "Rolemaster skills do not use the D6 allow-fumble option.");
if (expression.Kind == DiceExpressionKind.RolemasterOpenEndedPercentile)
{
if (!fumbleRange.HasValue)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Rolemaster open-ended percentile skills require a fumble range.");
if (fumbleRange < 0 || fumbleRange >= 96)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Fumble range must be between 0 and 95.");
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Success((0, false, fumbleRange));
}
if (fumbleRange.HasValue)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Fumble range is only valid for Rolemaster open-ended percentile skills.");
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Success((0, false, null));
}
if (fumbleRange.HasValue)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Fumble range is only supported for Rolemaster open-ended percentile skills.");
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Success((0, false, null));
} }
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRoll(RulesetKind ruleset, DiceExpression expression, Skill skill) private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRoll(RulesetKind ruleset, DiceExpression expression, Skill skill)
{ {
return ruleset == RulesetKind.D6 ? ComputeD6Roll(expression, skill.WildDice, skill.AllowFumble) : ComputeStandardRoll(expression); if (ruleset == RulesetKind.D6)
return ComputeD6Roll(expression, skill.WildDice, skill.AllowFumble);
if (ruleset == RulesetKind.Rolemaster)
{
return expression.Kind switch
{
DiceExpressionKind.RolemasterOpenEndedPercentile => ComputeRolemasterOpenEndedRoll(expression, skill.FumbleRange.GetValueOrDefault()),
_ => ComputeRolemasterStandardRoll(expression)
};
}
return ComputeStandardRoll(expression);
} }
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeStandardRoll(DiceExpression expression) private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeStandardRoll(DiceExpression expression)
@@ -926,6 +973,51 @@ public sealed class GameService : IGameService
return (total, BuildBreakdown(diceValues, expression.Modifier, total), dice); return (total, BuildBreakdown(diceValues, expression.Modifier, total), dice);
} }
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRolemasterStandardRoll(DiceExpression expression)
{
var diceValues = new int[expression.DiceCount];
var dice = new RollDieResult[expression.DiceCount];
var total = expression.Modifier;
for (var i = 0; i < expression.DiceCount; i += 1)
{
var value = m_DiceRoller.Roll(expression.Sides);
diceValues[i] = value;
dice[i] = CreateRolemasterDie(value, i + 1, RollDieKinds.RolemasterStandard, value);
total += value;
}
return (total, BuildBreakdown(diceValues, expression.Modifier, total), dice);
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRolemasterOpenEndedRoll(DiceExpression expression, int fumbleRange)
{
var initialRoll = m_DiceRoller.Roll(expression.Sides);
var followUpRolls = new List<int>();
int? initialContribution = initialRoll <= fumbleRange ? null : initialRoll;
var dice = new List<RollDieResult>
{
CreateRolemasterDie(initialRoll, 1, RollDieKinds.RolemasterOpenEndedInitial, initialContribution)
};
var baseTotal = initialRoll <= fumbleRange ? 0 : initialRoll;
var subtractFollowUps = false;
if (initialRoll >= 96)
{
followUpRolls.AddRange(RollRolemasterHighOpenEndedChain(dice, sequenceStart: 2, subtract: false));
baseTotal += followUpRolls.Sum();
}
else if (initialRoll <= fumbleRange)
{
subtractFollowUps = true;
followUpRolls.AddRange(RollRolemasterHighOpenEndedChain(dice, sequenceStart: 2, subtract: true));
baseTotal -= followUpRolls.Sum();
}
var total = baseTotal + expression.Modifier;
var breakdown = BuildRolemasterOpenEndedBreakdown(initialRoll, followUpRolls, subtractFollowUps, expression.Modifier, total);
return (total, breakdown, dice);
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeD6Roll(DiceExpression expression, int wildDice, bool allowFumble) private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeD6Roll(DiceExpression expression, int wildDice, bool allowFumble)
{ {
var initialDice = expression.DiceCount; var initialDice = expression.DiceCount;
@@ -1006,14 +1098,80 @@ public sealed class GameService : IGameService
return (total, BuildBreakdown(includedDice, expression.Modifier, total), dieResults); return (total, BuildBreakdown(includedDice, expression.Modifier, total), dieResults);
} }
private IEnumerable<int> RollRolemasterHighOpenEndedChain(List<RollDieResult> dice, int sequenceStart, bool subtract)
{
var followUpRolls = new List<int>();
var sequence = sequenceStart;
while (true)
{
var roll = m_DiceRoller.Roll(100);
followUpRolls.Add(roll);
dice.Add(CreateRolemasterDie(
roll,
sequence,
subtract ? RollDieKinds.RolemasterOpenEndedLowSubtract : RollDieKinds.RolemasterOpenEndedHigh,
subtract ? -roll : roll));
sequence += 1;
if (roll < 96)
break;
}
return followUpRolls;
}
private static RollDieResult CreateRolemasterDie(int roll, int sequence, string kind, int? signedContribution)
{
return new(roll, false, false, false, false, kind == RollDieKinds.RolemasterOpenEndedHigh, sequence, kind, signedContribution);
}
private static string BuildBreakdown(IEnumerable<int> diceValues, int modifier, int total) private static string BuildBreakdown(IEnumerable<int> diceValues, int modifier, int total)
{ {
var dicePart = string.Join("+", diceValues); var dicePart = string.Join("+", diceValues);
if (string.IsNullOrWhiteSpace(dicePart)) if (string.IsNullOrWhiteSpace(dicePart))
dicePart = "0"; dicePart = "0";
var modifierPart = modifier > 0 ? $"+{modifier}" : string.Empty; return BuildModifierBreakdown(dicePart, modifier, total);
return $"{dicePart}{modifierPart}={total}"; }
private static string BuildRolemasterOpenEndedBreakdown(int initialRoll, IReadOnlyList<int> followUpRolls, bool subtractFollowUps, int modifier, int total)
{
if (subtractFollowUps)
{
var segments = new List<string> { $"({FormatRolemasterTriggerRoll(initialRoll)})" };
segments.AddRange(followUpRolls.Select(roll => $"-{roll}"));
if (modifier > 0)
segments.Add($"+{modifier}");
else if (modifier < 0)
segments.Add(modifier.ToString());
return $"{string.Join(" ", segments)} = {total}";
}
var core = initialRoll.ToString();
if (followUpRolls.Count > 0)
{
var followUpBreakdown = string.Join("+", followUpRolls);
core = subtractFollowUps ? $"{core}-({followUpBreakdown})" : $"{core}+{followUpBreakdown}";
}
return BuildModifierBreakdown(core, modifier, total);
}
private static string FormatRolemasterTriggerRoll(int roll)
{
return roll.ToString("00");
}
private static string BuildModifierBreakdown(string core, int modifier, int total)
{
return modifier switch
{
> 0 => $"{core}+{modifier}={total}",
< 0 => $"{core}{modifier}={total}",
_ => $"{core}={total}"
};
} }
private ServiceResult<Guid?> ResolveSkillGroupForSkillChangeLocked(Guid? requestedSkillGroupId, Guid characterId) private ServiceResult<Guid?> ResolveSkillGroupForSkillChangeLocked(Guid? requestedSkillGroupId, Guid characterId)
@@ -1139,22 +1297,22 @@ public sealed class GameService : IGameService
private static CharacterSheetSkillGroup ToCharacterSheetSkillGroup(SkillGroup skillGroup) private static CharacterSheetSkillGroup ToCharacterSheetSkillGroup(SkillGroup skillGroup)
{ {
return new(skillGroup.Id, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble); return new(skillGroup.Id, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble, skillGroup.FumbleRange);
} }
private static SkillGroupSummary ToSkillGroupSummary(SkillGroup skillGroup) private static SkillGroupSummary ToSkillGroupSummary(SkillGroup skillGroup)
{ {
return new(skillGroup.Id, skillGroup.CharacterId, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble); return new(skillGroup.Id, skillGroup.CharacterId, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble, skillGroup.FumbleRange);
} }
private static CharacterSheetSkill ToCharacterSheetSkill(Skill skill) private static CharacterSheetSkill ToCharacterSheetSkill(Skill skill)
{ {
return new(skill.Id, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble); return new(skill.Id, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange);
} }
private static SkillSummary ToSkillSummary(Skill skill) private static SkillSummary ToSkillSummary(Skill skill)
{ {
return new(skill.Id, skill.CharacterId, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble); return new(skill.Id, skill.CharacterId, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange);
} }
private static RollResult ToRollResult(RollLogEntry entry, IReadOnlyList<RollDieResult> dice) private static RollResult ToRollResult(RollLogEntry entry, IReadOnlyList<RollDieResult> dice)
@@ -1213,6 +1371,9 @@ public sealed class GameService : IGameService
if (dice.Count == 0) if (dice.Count == 0)
return "No detail available."; return "No detail available.";
if (dice.Any(die => IsRolemasterDieKind(die.Kind)))
return BuildRolemasterCompactLogSummary(dice);
var preview = string.Join(", ", dice.Take(3).Select(die => die.Roll.ToString())); var preview = string.Join(", ", dice.Take(3).Select(die => die.Roll.ToString()));
if (dice.Count > 3) if (dice.Count > 3)
preview = $"{preview}, ..."; preview = $"{preview}, ...";
@@ -1230,6 +1391,48 @@ public sealed class GameService : IGameService
return tags.Count == 0 ? preview : $"{preview} | {string.Join(", ", tags)}"; return tags.Count == 0 ? preview : $"{preview} | {string.Join(", ", tags)}";
} }
private static string BuildRolemasterCompactLogSummary(IReadOnlyList<RollDieResult> dice)
{
var openEndedInitial = dice.FirstOrDefault(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal));
if (openEndedInitial is not null)
{
var highFollowUps = dice
.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedHigh, StringComparison.Ordinal))
.Select(die => die.Roll.ToString())
.ToArray();
if (highFollowUps.Length > 0)
return $"{openEndedInitial.Roll} + {string.Join(" + ", highFollowUps)} | open-ended high";
var lowFollowUps = dice
.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedLowSubtract, StringComparison.Ordinal))
.Select(die => die.Roll.ToString())
.ToArray();
if (lowFollowUps.Length > 0)
return $"({FormatRolemasterTriggerRoll(openEndedInitial.Roll)}) {string.Join(" ", lowFollowUps.Select(roll => $"-{roll}"))} | open-ended low";
return $"{openEndedInitial.Roll} | open-ended";
}
if (dice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterStandard, StringComparison.Ordinal)))
{
var preview = string.Join(" + ", dice.Take(3).Select(die => die.Roll.ToString()));
if (dice.Count > 3)
preview = $"{preview} + ...";
return $"{preview} | rolemaster";
}
return string.Join(", ", dice.Take(3).Select(die => die.Roll.ToString()));
}
private static bool IsRolemasterDieKind(string? kind)
{
return kind is RollDieKinds.RolemasterStandard or
RollDieKinds.RolemasterOpenEndedInitial or
RollDieKinds.RolemasterOpenEndedHigh or
RollDieKinds.RolemasterOpenEndedLowSubtract;
}
private bool CanViewRollLocked(UserAccount user, Campaign campaign, RollLogEntry entry) private bool CanViewRollLocked(UserAccount user, Campaign campaign, RollLogEntry entry)
{ {
return CanViewCampaignLocked(user.Id, campaign.Id) && return CanViewCampaignLocked(user.Id, campaign.Id) &&
@@ -1658,7 +1861,8 @@ public sealed class GameService : IGameService
Name = skill.Name, Name = skill.Name,
DiceRollDefinition = skill.DiceRollDefinition, DiceRollDefinition = skill.DiceRollDefinition,
WildDice = skill.WildDice, WildDice = skill.WildDice,
AllowFumble = skill.AllowFumble AllowFumble = skill.AllowFumble,
FumbleRange = skill.FumbleRange
}; };
} }
@@ -1671,7 +1875,8 @@ public sealed class GameService : IGameService
Name = skillGroup.Name, Name = skillGroup.Name,
DiceRollDefinition = skillGroup.DiceRollDefinition, DiceRollDefinition = skillGroup.DiceRollDefinition,
WildDice = skillGroup.WildDice, WildDice = skillGroup.WildDice,
AllowFumble = skillGroup.AllowFumble AllowFumble = skillGroup.AllowFumble,
FumbleRange = skillGroup.FumbleRange
}; };
} }

View File

@@ -28,11 +28,11 @@ public interface IGameService
ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId); ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId);
ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken); ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken);
ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble); ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null);
ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble); ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null);
ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId); ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId);
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null); ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null);
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null); ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null);
ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId); ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId);
ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId); ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId);

View File

@@ -222,6 +222,12 @@ select:focus-visible {
margin: 0; margin: 0;
} }
.field-help {
margin: -0.1rem 0 0;
color: var(--muted);
font-size: 0.85rem;
}
.status-message { .status-message {
font-weight: 700; font-weight: 700;
} }
@@ -528,15 +534,16 @@ select:focus-visible {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 2.1rem; min-width: 2.1rem;
height: 2.1rem; height: 2.1rem;
padding-top: 4px; padding: 0.2rem 0.45rem 0;
border: 2px solid #2a2418; border: 2px solid #2a2418;
border-radius: 0.45rem; border-radius: 0.45rem;
background: #ffffff; background: #ffffff;
color: #1f1a13; color: #1f1a13;
font-size: 2rem; font-size: 2rem;
line-height: 1; line-height: 1;
font-variant-numeric: tabular-nums;
} }
.die-chip.wild { .die-chip.wild {
@@ -565,6 +572,36 @@ select:focus-visible {
border-style: dashed; border-style: dashed;
} }
.die-chip.rolemaster-initiative,
.die-chip.rolemaster-percentile,
.die-chip.rolemaster-open-ended-initial,
.die-chip.rolemaster-open-ended-high,
.die-chip.rolemaster-open-ended-low-subtract {
padding-top: 0;
font-size: 1rem;
font-weight: 700;
line-height: 1.1;
}
.die-chip.rolemaster-initiative,
.die-chip.rolemaster-percentile,
.die-chip.rolemaster-open-ended-initial {
background: #f8f1df;
color: #3f2f12;
}
.die-chip.rolemaster-open-ended-high {
background: #dff6df;
color: #1d5b26;
border-color: #2a7c39;
}
.die-chip.rolemaster-open-ended-low-subtract {
background: #ffe1dc;
color: #8a2217;
border-color: #b74334;
}
.empty, .empty,
.muted { .muted {
color: var(--muted); color: var(--muted);

287
TASKS.md
View File

@@ -1,287 +0,0 @@
# Payload And Serialization Refactor Plan
## Objective
Reduce the risk of future Blazor Server circuit disconnects by shrinking payloads, removing unnecessary serialization hops, and making live refreshes more granular.
The most expensive transport pattern is:
- browser `fetch`
- JSON parse in JavaScript
- JS interop result marshalled over the Blazor circuit
- JSON deserialization in .NET
That path means every read model still competes with the SignalR hub message ceiling and pays serialization cost twice.
## Current Baseline
- `GET /api/campaigns`: about `222 B`
- `GET /api/campaigns/{id}`: about `1.0 KB`
- `GET /api/characters/{id}/sheet`: about `11.3 KB`
- `GET /api/campaigns/{id}/log`: about `13.8 KB`
- Workspace refresh currently reloads roster, selected character sheet, and log together when the state SSE reports a version change.
- The API client still uses JS interop for all reads and writes through `rpgRollerApi.request`.
## Target Outcomes
- Keep normal interactive responses well below the default Blazor circuit limit without depending on hub-size increases.
- Eliminate double JSON handling for the workspace read path.
- Avoid retransmitting unchanged roster, sheet, and log data after every state change.
- Establish payload and allocation guardrails so regressions are detected in tests.
## Recommended Delivery Order
1. Remove JS interop from workspace API reads.
2. Split live refresh into change-specific reloads.
3. Make campaign log loading incremental instead of retransmitting the latest 100 entries.
4. Trim DTO shape and serialization overhead.
5. Add measurement, tests, and payload budgets.
## Phase 1: Remove The JS Interop API Bottleneck
### Goal
Move workspace data reads off the `fetch -> JS -> SignalR -> .NET` path.
### Recommendation
Introduce a server-side workspace query facade and call it directly from Blazor components instead of routing workspace reads through `RpgRollerApiClient`.
### Why This First
- Highest impact on serialization overhead.
- Removes the hub-size ceiling from normal workspace query results.
- Simplifies error handling and reduces duplicate parsing logic.
### Implementation Tasks
- Add a scoped server-side query service for the authenticated workspace.
- Resolve the session token from the current `HttpContext` or a dedicated session abstraction.
- Move these read flows from `RpgRollerApiClient` to the query service:
- `GetMe`
- `GetCampaigns`
- `GetCharacterCampaignOptions`
- `GetCampaign`
- `GetCharacterSheet`
- `GetCampaignLog`
Keep browser JS interop only for browser-only concerns:
- session storage
- SSE wiring
- DOM scrolling helpers
- Keep HTTP API endpoints for external callers and integration tests.
- Leave mutation endpoints in place initially, then decide whether mutations should also move server-side or stay as HTTP calls.
### File Areas
- `RpgRoller/Components/RpgRollerApiClient.cs`
- `RpgRoller/Components/Pages/Workspace.razor.cs`
- `RpgRoller/Api/SessionTokenHttpContextExtensions.cs`
- new workspace query service under `RpgRoller/Components` or `RpgRoller/Services`
### Acceptance Criteria
- Workspace reads no longer call `rpgRollerApi.request`.
- Opening play and management screens does not depend on JS interop payload size.
- Existing API integration tests still pass.
## Phase 2: Replace Full Scope Refreshes With Targeted Refreshes
### Goal
Stop reloading roster, selected sheet, and log together for every state change.
### Recommendation
Replace the single campaign version event with typed change notifications or multiple independent versions.
### Options
- Preferred: emit typed SSE events such as `roster-changed`, `character-sheet-changed`, and `log-appended`.
- Acceptable: keep one event stream but include separate version counters for roster, character state, and log state.
### Implementation Tasks
- Extend the server-side state event model to expose change categories.
- Update mutation paths in `GameService` to mark the relevant change areas.
- Update `Workspace.razor.cs` so the handler refreshes only the affected slice:
- roster changes reload `CampaignRoster`
- skill and group changes reload `CharacterSheet`
- roll events append or refresh only the log
- avoid reloading the selected character sheet when another character changes
- avoid reloading the log when only roster metadata changes
### File Areas
- `RpgRoller/Api/StateEventEndpoints.cs`
- `RpgRoller/Services/GameService.cs`
- `RpgRoller/Components/Pages/Workspace.razor.cs`
- `RpgRoller/wwwroot/js/rpgroller-api.js`
### Acceptance Criteria
- A new roll does not trigger a roster reload.
- Renaming a character does not trigger a log reload unless log labels depend on that mutation.
- State refresh traffic is materially lower in browser and server traces.
## Phase 3: Make Campaign Log Loading Incremental
### Goal
Stop retransmitting the same log entries after every roll.
### Recommendation
Add incremental log APIs and append on the client.
### Implementation Tasks
- Add query parameters such as:
- `afterRollId`
- `sinceTimestamp`
- `limit`
- retain an initial bounded load for first render
- add an incremental mode for live updates
- keep server ordering stable and deterministic
- update the workspace to append new entries instead of replacing the whole log
- trim old entries client-side to a fixed window
- preserve the visibility rules for GM, owner, and observers
### Contract Changes
- Introduce a dedicated log page result:
- entries
- cursor or last seen roll id
- optional `hasMore`
### Acceptance Criteria
- A new roll causes only the new log entry or entries to cross the wire.
- Reconnect can rebuild log state without downloading unnecessary history.
- Existing visibility behavior remains unchanged.
## Phase 4: Split Log Summary From Log Detail
### Goal
Reduce the size of the hottest payload even further.
### Recommendation
Do not send full dice arrays and long breakdown strings for every log row by default.
### Implementation Tasks
- Introduce `CampaignLogListEntry` for the list view.
- Keep only fields needed to render the collapsed row:
- roll id
- roller label
- skill label
- character label
- result
- visibility
- timestamp
- compact summary text
- add `GET /api/rolls/{rollId}` or equivalent detail lookup for expanded inspection
- update the log UI to lazy-load detail when a row is expanded
### Expected Benefit
This should cut the log list payload materially because `Dice` and `Breakdown` are currently repeated for every row and are the least compressible fields in the list.
## Phase 5: Trim DTO Shape To Match The View
### Goal
Remove repeated fields that are not needed by the consuming UI.
### Recommendations
- Replace `CampaignSummary.Gm` and `CampaignRoster.Gm` full `UserSummary` usage with a slimmer campaign GM DTO if the UI only needs `Id` and `DisplayName`.
- Remove parent-scope identifiers from child records where the endpoint already provides that scope.
- candidate examples:
- `CampaignLogEntry.CampaignId`
- `SkillSummary.CharacterId` inside `CharacterSheet`
- `SkillGroupSummary.CharacterId` inside `CharacterSheet`
- review whether owner ids are needed in all list views or whether some can be replaced with display labels and booleans
### Guardrail
Do not over-optimize DTOs until the consuming components have been made explicit. Only remove a field after all consumers are verified.
## Phase 6: Reduce Serializer CPU And Allocation Overhead
### Goal
Lower per-request CPU and allocation cost after the major transport fixes are in place.
### Recommendations
- Introduce source-generated `System.Text.Json` contexts for the hot contracts.
- Reuse serializer options consistently rather than relying on repeated default metadata discovery.
- Review whether any list contracts can be exposed as arrays end-to-end to reduce intermediate allocations.
- If HTTP remains in the path for some calls, ensure response compression is enabled for normal API responses to reduce browser transfer cost.
### Note
This phase is worthwhile, but it should follow the transport refactor. Serializer tuning alone will not solve circuit-size problems.
## Phase 7: Add Payload Budgets And Regression Tests
### Goal
Prevent a future regression from silently reintroducing oversized read models.
### Implementation Tasks
- Add integration tests that serialize representative contracts and assert upper bounds.
- Add service or API tests for log pagination and incremental fetch semantics.
- Add workspace tests for targeted refresh behavior.
- Add a small benchmark or diagnostic test for hot payload serialization if practical.
- Document soft payload budgets for any remaining JS interop responses.
### Suggested Budgets
- Any remaining JS interop response: prefer under `16 KB`
- initial character sheet response: target under `12 KB`
- initial log list response: target under `8 KB` after summary/detail split
- incremental live update response: target under `2 KB`
## Delivery Notes
- Do not raise the Blazor hub message limit again as the primary fix.
- Keep the existing HTTP API stable where possible so tests and external tooling do not break.
- Prefer introducing new, view-specific contracts instead of reusing broad aggregate models.
- Measure payload size with representative admin and non-admin datasets after each phase.
## Proposed Milestones
### Milestone A
Move workspace reads off JS interop and keep behavior unchanged.
### Milestone B
Introduce targeted SSE-driven refreshes without yet changing log contract shape.
### Milestone C
Add incremental log loading and client append behavior.
### Milestone D
Split log summary from log detail and trim DTOs.
### Milestone E
Add serializer optimizations, payload budget tests, and final documentation updates.
## Definition Of Done
- Workspace read models are no longer limited by Blazor JS interop payload size.
- Live updates no longer reload unrelated slices.
- The campaign log is loaded incrementally.
- The hottest contracts are explicitly sized for their views.
- Payload budgets are enforced by tests.
- The default Blazor hub receive limit remains unchanged.

View File

@@ -900,6 +900,16 @@
}, },
"allowFumble": { "allowFumble": {
"type": "boolean" "type": "boolean"
},
"skillGroupId": {
"type": "string",
"format": "uuid",
"nullable": true
},
"fumbleRange": {
"type": "integer",
"format": "int32",
"nullable": true
} }
}, },
"required": [ "required": [
@@ -924,6 +934,16 @@
}, },
"allowFumble": { "allowFumble": {
"type": "boolean" "type": "boolean"
},
"skillGroupId": {
"type": "string",
"format": "uuid",
"nullable": true
},
"fumbleRange": {
"type": "integer",
"format": "int32",
"nullable": true
} }
}, },
"required": [ "required": [
@@ -944,6 +964,11 @@
"type": "string", "type": "string",
"format": "uuid" "format": "uuid"
}, },
"skillGroupId": {
"type": "string",
"format": "uuid",
"nullable": true
},
"name": { "name": {
"type": "string" "type": "string"
}, },
@@ -956,6 +981,11 @@
}, },
"allowFumble": { "allowFumble": {
"type": "boolean" "type": "boolean"
},
"fumbleRange": {
"type": "integer",
"format": "int32",
"nullable": true
} }
}, },
"required": [ "required": [
@@ -999,6 +1029,20 @@
}, },
"added": { "added": {
"type": "boolean" "type": "boolean"
},
"sequence": {
"type": "integer",
"format": "int32",
"nullable": true
},
"kind": {
"type": "string",
"nullable": true
},
"signedContribution": {
"type": "integer",
"format": "int32",
"nullable": true
} }
}, },
"required": [ "required": [

76
package-lock.json generated Normal file
View File

@@ -0,0 +1,76 @@
{
"name": "rpgroller",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rpgroller",
"devDependencies": {
"@playwright/test": "^1.59.1"
}
},
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

12
package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "rpgroller",
"private": true,
"scripts": {
"e2e": "playwright test",
"e2e:smoke": "playwright test tests/e2e/smoke.spec.js --reporter=line",
"e2e:install": "playwright install chromium"
},
"devDependencies": {
"@playwright/test": "^1.59.1"
}
}

13
playwright.config.js Normal file
View File

@@ -0,0 +1,13 @@
const { defineConfig } = require("@playwright/test");
module.exports = defineConfig({
testDir: "./tests/e2e",
timeout: 30_000,
fullyParallel: false,
reporter: "line",
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:5000",
headless: true,
trace: "retain-on-failure"
}
});

View File

@@ -1,6 +1,7 @@
param( param(
[switch]$SkipDotnetRestore, [switch]$SkipDotnetRestore,
[switch]$SkipBuild [switch]$SkipBuild,
[switch]$SkipPlaywright
) )
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
@@ -36,6 +37,14 @@ try {
} }
} }
Invoke-Step -Name "Restore Node dependencies" -Action {
npm ci
}
Invoke-Step -Name "Ensure Playwright browser" -Action {
npm exec playwright install chromium
}
Invoke-Step -Name "Run tests" -Action { Invoke-Step -Name "Run tests" -Action {
if ($SkipBuild) { if ($SkipBuild) {
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --verbosity normal --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --verbosity normal --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings
@@ -49,6 +58,12 @@ try {
pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70 pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70
} }
if (-not $SkipPlaywright) {
Invoke-Step -Name "Run Playwright smoke test" -Action {
pwsh ./scripts/run-playwright.ps1
}
}
Write-Host "CI checks passed." Write-Host "CI checks passed."
} }
finally { finally {

View File

@@ -0,0 +1,61 @@
param(
[string]$BaseUrl = "http://127.0.0.1:5095",
[string]$Spec = "tests/e2e/smoke.spec.js"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Split-Path -Parent $scriptDir
$appUrl = [Uri]$BaseUrl
$healthUrl = "$BaseUrl/api/health"
$tempDbPath = Join-Path $env:TEMP ("rpgroller-playwright-{0}.db" -f [Guid]::NewGuid().ToString("N"))
$process = $null
Push-Location $repoRoot
try {
$env:ConnectionStrings__RpgRoller = "Data Source=$tempDbPath"
$env:PLAYWRIGHT_BASE_URL = $BaseUrl
$process = Start-Process dotnet -ArgumentList @(
"run",
"--project",
"RpgRoller/RpgRoller.csproj",
"--urls",
$BaseUrl
) -WorkingDirectory $repoRoot -PassThru
$response = $null
for ($i = 0; $i -lt 60; $i++) {
try {
$response = Invoke-WebRequest -Uri $healthUrl -UseBasicParsing -TimeoutSec 2
if ($response.StatusCode -eq 200) {
break
}
}
catch {
Start-Sleep -Milliseconds 500
}
Start-Sleep -Milliseconds 500
}
if (-not $response -or $response.StatusCode -ne 200) {
throw "Application failed to start on $BaseUrl."
}
npm exec playwright test $Spec -- --reporter=line
if ($LASTEXITCODE -ne 0) {
throw "Playwright exited with code $LASTEXITCODE."
}
}
finally {
if ($process -and -not $process.HasExited) {
Stop-Process -Id $process.Id -Force
}
Remove-Item Env:\ConnectionStrings__RpgRoller -ErrorAction SilentlyContinue
Remove-Item Env:\PLAYWRIGHT_BASE_URL -ErrorAction SilentlyContinue
Pop-Location
}

134
tests/e2e/smoke.spec.js Normal file
View File

@@ -0,0 +1,134 @@
const { test, expect } = require("@playwright/test");
async function postJson(request, url, data) {
const response = await request.post(url, { data });
expect(response.ok()).toBeTruthy();
return await response.json();
}
async function registerAndLogin(request, username, displayName) {
await postJson(request, "/api/auth/register", {
username,
password: "Password123",
displayName
});
const loginResponse = await request.post("/api/auth/login", {
data: {
username,
password: "Password123"
}
});
expect(loginResponse.ok()).toBeTruthy();
}
test("home page loads auth entry points", async ({ page }) => {
await page.goto("/");
await expect(page.locator("h1")).toContainText("RpgRoller");
await expect(page.getByRole("heading", { name: "Register" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Login" })).toBeVisible();
await expect(page.getByLabel("Username").first()).toBeVisible();
await expect(page.getByLabel("Password").nth(1)).toBeVisible();
});
test("Rolemaster open-ended roll detail renders specialized dice chips", async ({ page, context }) => {
const username = `rm-${Date.now()}`;
const displayName = "Rolemaster Smoke";
await registerAndLogin(context.request, username, displayName);
const campaign = await postJson(context.request, "/api/campaigns", {
name: "Rolemaster Smoke",
rulesetId: "rolemaster"
});
const character = await postJson(context.request, "/api/characters", {
name: "Open Ender",
campaignId: campaign.id
});
const skill = await postJson(context.request, `/api/characters/${character.id}/skills`, {
name: "Open Sight",
diceRollDefinition: "d100!+85",
wildDice: 0,
allowFumble: false,
fumbleRange: 95
});
await postJson(context.request, `/api/skills/${skill.id}/roll`, { visibility: "public" });
await page.goto("/");
await expect(page.getByText("Campaign Log")).toBeVisible();
const logEntry = page.locator(".log-panel .log-entry-toggle").first();
await expect(logEntry).toBeVisible();
await logEntry.click();
const rolemasterFollowUpDice = page.locator(".die-chip.rolemaster-open-ended-high, .die-chip.rolemaster-open-ended-low-subtract");
await expect(rolemasterFollowUpDice.first()).toBeVisible();
await expect(page.locator(".log-detail .roll-dice-strip")).toBeVisible();
});
test("Rolemaster UI exposes conditional create and edit fields", async ({ page, context }) => {
const username = `rm-ui-${Date.now()}`;
await registerAndLogin(context.request, username, "Rolemaster UI");
const campaign = await postJson(context.request, "/api/campaigns", {
name: "Rolemaster UI Campaign",
rulesetId: "rolemaster"
});
const character = await postJson(context.request, "/api/characters", {
name: "UI Character",
campaignId: campaign.id
});
await postJson(context.request, `/api/characters/${character.id}/skill-groups`, {
name: "Awareness",
diceRollDefinition: "d100!+15",
wildDice: 0,
allowFumble: false,
fumbleRange: 5
});
await postJson(context.request, `/api/characters/${character.id}/skills`, {
name: "Perception",
diceRollDefinition: "d100!+25",
wildDice: 0,
allowFumble: false,
fumbleRange: 5
});
await page.goto("/");
await expect(page.locator("#workspace-screen-menu-button")).toBeVisible();
await page.locator("#workspace-screen-menu-button").click();
await page.getByRole("menuitem", { name: "Campaign Management" }).click();
await page.getByRole("button", { name: "Add campaign" }).click();
await expect(page.locator("#campaign-ruleset option[value='rolemaster']")).toHaveText("Rolemaster");
await page.getByRole("button", { name: "Cancel" }).click();
await page.locator("#workspace-screen-menu-button").click();
await page.getByRole("menuitem", { name: "Play" }).click();
await page.getByRole("button", { name: "Add group" }).click();
await expect(page.locator("#skill-group-wild-dice")).toHaveCount(0);
await expect(page.locator("#skill-group-expression")).toHaveValue("d100");
await page.locator("#skill-group-expression").fill("d100!+15");
await expect(page.locator("#skill-group-fumble-range")).toBeVisible();
await page.locator("#skill-group-fumble-range").fill("");
await page.getByRole("button", { name: "Create Group" }).click();
await expect(page.getByText("Open-ended Rolemaster groups require a fumble range.")).toBeVisible();
await page.getByRole("button", { name: "Cancel" }).click();
await page.getByRole("button", { name: "Add skill" }).first().click();
await expect(page.locator("#skill-create-expression")).toHaveValue("d100!+15");
await page.locator("#skill-create-expression").fill("15d10");
await expect(page.locator("#skill-create-fumble-range")).toHaveCount(0);
await page.locator("#skill-create-expression").fill("d100!+25");
await expect(page.locator("#skill-create-fumble-range")).toBeVisible();
await page.getByRole("button", { name: "Cancel" }).click();
await page.locator("button[title='Edit skill']").first().click();
await expect(page.locator("#skill-edit-expression")).toHaveValue("d100!+25");
await expect(page.locator("#skill-edit-fumble-range")).toHaveValue("5");
await page.locator("#skill-edit-expression").fill("d10");
await expect(page.locator("#skill-edit-fumble-range")).toHaveCount(0);
await page.getByRole("button", { name: "Cancel" }).click();
});