Compare commits
20 Commits
feature/pa
...
feature/ro
| Author | SHA1 | Date | |
|---|---|---|---|
| 9581442cab | |||
| 7d91e7c900 | |||
| 923c6ae26d | |||
| f0dd79e589 | |||
| e5f00fa693 | |||
| 61ea310179 | |||
| 960197354a | |||
| 0059fde74f | |||
| 9b9927084b | |||
| 48439fd21d | |||
| 90afe3b06b | |||
| 13c6215c89 | |||
| da9dc24d8e | |||
| f750f5adc4 | |||
| 31dcb0c4a9 | |||
| ac586b0e55 | |||
| fadb7efd64 | |||
| bf6113f790 | |||
| 0ac1bda10b | |||
| f04f4aa08a |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,6 +11,8 @@ artifacts/
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
node_modules/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# User secrets / configs
|
||||
appsettings.Development.json
|
||||
|
||||
@@ -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'.
|
||||
- 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.
|
||||
- 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, 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 frontend change, verify the results using playwright.
|
||||
- After every iteration, update all related documentation according to the change.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
28
README.md
28
README.md
@@ -47,6 +47,7 @@ Backend state persistence:
|
||||
Gameplay capabilities now include:
|
||||
|
||||
- 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 and skill-group deletion 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
|
||||
- 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`)
|
||||
- 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
|
||||
|
||||
- .NET SDK 10.0+
|
||||
- PowerShell 7+
|
||||
- Node.js 22+
|
||||
- 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
|
||||
|
||||
@@ -80,6 +90,21 @@ Gameplay capabilities now include:
|
||||
```
|
||||
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`:
|
||||
|
||||
- `RpgRoller: Server`
|
||||
@@ -99,6 +124,7 @@ dotnet dotnet-ef migrations add <MigrationName> --project RpgRoller/RpgRoller.cs
|
||||
|
||||
- Runtime frontend is Blazor Server with interactive components.
|
||||
- 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.
|
||||
- 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`.
|
||||
@@ -112,7 +138,7 @@ dotnet dotnet-ef migrations add <MigrationName> --project RpgRoller/RpgRoller.cs
|
||||
```powershell
|
||||
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:
|
||||
```powershell
|
||||
pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70
|
||||
|
||||
@@ -65,6 +65,49 @@ public sealed class CampaignApiTests : ApiTestBase
|
||||
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]
|
||||
public async Task SkillGroupsAndOwnerTransfer_WorkThroughApi()
|
||||
{
|
||||
|
||||
88
RpgRoller.Tests/Api/RolemasterApiTests.cs
Normal file
88
RpgRoller.Tests/Api/RolemasterApiTests.cs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,9 @@ public sealed class SystemApiTests : ApiTestBase
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
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 LoginAsync(client, "sse", "Password123");
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using RpgRoller.Data;
|
||||
using RpgRoller.Hosting;
|
||||
|
||||
@@ -125,6 +127,16 @@ public sealed class HostingCoverageTests
|
||||
|
||||
Assert.Contains("WildDice", 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();
|
||||
rollTableInfoCommand.CommandText = "PRAGMA table_info('RollLogEntries');";
|
||||
@@ -183,5 +195,130 @@ public sealed class HostingCoverageTests
|
||||
rolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226160859_AddAuthorizationRolesAndCampaignDeletion';";
|
||||
var rolesHistoryCount = Convert.ToInt32(rolesHistoryCommand.ExecuteScalar());
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,29 @@ public sealed class PayloadBudgetTests
|
||||
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]
|
||||
public void RollResultPayload_StaysWithinJsInteropBudget()
|
||||
{
|
||||
@@ -107,6 +130,41 @@ public sealed class PayloadBudgetTests
|
||||
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)
|
||||
{
|
||||
var byteCount = JsonSerializer.SerializeToUtf8Bytes(payload, SerializerOptions).Length;
|
||||
@@ -123,5 +181,15 @@ public sealed class PayloadBudgetTests
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -7,28 +7,53 @@ public sealed class DiceRulesTests
|
||||
{
|
||||
Assert.Equal(RulesetKind.D6, DiceRules.TryParseRulesetId("d6"));
|
||||
Assert.Equal(RulesetKind.Dnd5e, DiceRules.TryParseRulesetId("dnd5e"));
|
||||
Assert.Equal(RulesetKind.Rolemaster, DiceRules.TryParseRulesetId("rolemaster"));
|
||||
Assert.Null(DiceRules.TryParseRulesetId("unknown"));
|
||||
|
||||
var d6 = DiceRules.ParseExpression(RulesetKind.D6, "5D+4");
|
||||
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 badFormat = DiceRules.ParseExpression(RulesetKind.Dnd5e, "abc");
|
||||
var tooManyDice = DiceRules.ParseExpression(RulesetKind.D6, "51D+1");
|
||||
var tooManySides = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d1001");
|
||||
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");
|
||||
|
||||
Assert.True(d6.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(badFormat.Succeeded);
|
||||
Assert.False(tooManyDice.Succeeded);
|
||||
Assert.False(tooManySides.Succeeded);
|
||||
Assert.False(tooLargeModifier.Succeeded);
|
||||
Assert.False(negativeDndModifier.Succeeded);
|
||||
Assert.False(invalidRolemasterOpenEndedFormat.Succeeded);
|
||||
Assert.False(tooNegativeRolemasterModifier.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("dnd5e", DiceRules.ToRulesetId(RulesetKind.Dnd5e));
|
||||
Assert.Equal("rolemaster", DiceRules.ToRulesetId(RulesetKind.Rolemaster));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => DiceRules.ToRulesetId((RulesetKind)99));
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,11 @@ public sealed class ServiceCampaignTests
|
||||
service.Register("gm", "Password123", "GM");
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
|
||||
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");
|
||||
Assert.False(invalidRuleset.Succeeded);
|
||||
Assert.Equal("rolemaster", rolemasterCampaign.RulesetId);
|
||||
|
||||
var noCampaignCharacter = service.CreateCharacter(gmSession, "Hero", Guid.NewGuid());
|
||||
Assert.False(noCampaignCharacter.Succeeded);
|
||||
|
||||
@@ -92,4 +92,31 @@ public sealed class ServicePersistenceTests
|
||||
Assert.False(invalidExpressionHarness.Service.RollSkill(ownerSession, skill.Id, "public").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);
|
||||
}
|
||||
}
|
||||
|
||||
164
RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs
Normal file
164
RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -174,4 +174,55 @@ public sealed class ServiceSkillGroupAndOwnershipTests
|
||||
var campaignAfterDeletes = ServiceTestSupport.GetValue(service.GetCampaign(gmSession, campaign.Id));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,11 +87,11 @@ public sealed class WorkspaceQueryServiceTests
|
||||
public ServiceResult<bool> DeleteCharacter(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<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, 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) => 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, int? fumbleRange = null) => 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> UpdateSkill(string sessionToken, Guid skillId, 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, int? fumbleRange = null) => 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<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility) => throw new NotSupportedException();
|
||||
|
||||
@@ -9,13 +9,13 @@ internal static class SkillEndpoints
|
||||
{
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -27,13 +27,13 @@ internal static class SkillEndpoints
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<base href="@BaseHref"/>
|
||||
<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.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">
|
||||
|
||||
@@ -42,18 +42,22 @@ public sealed class CharacterFormModel
|
||||
public sealed class SkillFormModel
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string RulesetId { get; set; } = string.Empty;
|
||||
public string DiceRollDefinition { get; set; } = string.Empty;
|
||||
public string SkillGroupId { get; set; } = string.Empty;
|
||||
public int WildDice { get; set; }
|
||||
public bool AllowFumble { get; set; }
|
||||
public int? FumbleRange { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SkillGroupFormModel
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string RulesetId { get; set; } = string.Empty;
|
||||
public string DiceRollDefinition { get; set; } = string.Empty;
|
||||
public int WildDice { get; set; }
|
||||
public bool AllowFumble { get; set; }
|
||||
public int? FumbleRange { get; set; }
|
||||
}
|
||||
|
||||
public enum HomeViewMode
|
||||
|
||||
@@ -152,13 +152,14 @@
|
||||
}
|
||||
|
||||
<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))
|
||||
{
|
||||
<p class="field-error">@expressionError</p>
|
||||
}
|
||||
|
||||
@if (IsD6)
|
||||
@if (IsD6Ruleset)
|
||||
{
|
||||
<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"/>
|
||||
@@ -170,6 +171,19 @@
|
||||
<label for="skill-group-allow-fumble">Prototype allow fumble</label>
|
||||
<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))
|
||||
{
|
||||
@@ -192,7 +206,7 @@
|
||||
<SkillFormModal
|
||||
Visible="ShowCreateSkillModal"
|
||||
AutoFocusName="true"
|
||||
IsD6="IsD6"
|
||||
RulesetId="@SelectedCampaignRulesetId"
|
||||
Title="Create Skill"
|
||||
SubmitLabel="Create Skill"
|
||||
NameInputId="skill-create-name"
|
||||
@@ -200,6 +214,7 @@
|
||||
SkillGroupInputId="skill-create-group"
|
||||
WildDiceInputId="skill-create-wild-dice"
|
||||
AllowFumbleInputId="skill-create-allow-fumble"
|
||||
FumbleRangeInputId="skill-create-fumble-range"
|
||||
InitialModel="CreateSkillInitialModel"
|
||||
FormVersion="CreateSkillFormVersion"
|
||||
SelectedCharacterId="SelectedCharacterId"
|
||||
@@ -211,7 +226,7 @@
|
||||
|
||||
<SkillFormModal
|
||||
Visible="ShowEditSkillModal"
|
||||
IsD6="IsD6"
|
||||
RulesetId="@SelectedCampaignRulesetId"
|
||||
Title="Edit Skill"
|
||||
SubmitLabel="Save Skill"
|
||||
NameInputId="skill-edit-name"
|
||||
@@ -219,6 +234,7 @@
|
||||
SkillGroupInputId="skill-edit-group"
|
||||
WildDiceInputId="skill-edit-wild-dice"
|
||||
AllowFumbleInputId="skill-edit-allow-fumble"
|
||||
FumbleRangeInputId="skill-edit-fumble-range"
|
||||
InitialModel="EditSkillInitialModel"
|
||||
FormVersion="EditSkillFormVersion"
|
||||
SelectedCharacterId="SelectedCharacterId"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
@@ -16,12 +17,17 @@ public partial class CharacterPanel
|
||||
CreateSkillInitialModel = new()
|
||||
{
|
||||
Name = string.Empty,
|
||||
RulesetId = SelectedCampaignRulesetId,
|
||||
DiceRollDefinition = selectedGroup?.DiceRollDefinition ?? string.Empty,
|
||||
SkillGroupId = selectedGroup?.Id.ToString() ?? string.Empty,
|
||||
WildDice = selectedGroup?.WildDice ?? (IsD6 ? 1 : 0),
|
||||
AllowFumble = selectedGroup?.AllowFumble ?? IsD6
|
||||
WildDice = selectedGroup?.WildDice ?? (IsD6Ruleset ? 1 : 0),
|
||||
AllowFumble = selectedGroup?.AllowFumble ?? IsD6Ruleset,
|
||||
FumbleRange = selectedGroup?.FumbleRange
|
||||
};
|
||||
|
||||
if (IsRolemasterRuleset && string.IsNullOrWhiteSpace(CreateSkillInitialModel.DiceRollDefinition))
|
||||
CreateSkillInitialModel.DiceRollDefinition = "d100";
|
||||
|
||||
CreateSkillFormVersion++;
|
||||
ShowCreateSkillModal = true;
|
||||
}
|
||||
@@ -32,10 +38,12 @@ public partial class CharacterPanel
|
||||
EditSkillInitialModel = new()
|
||||
{
|
||||
Name = skill.Name,
|
||||
RulesetId = SelectedCampaignRulesetId,
|
||||
DiceRollDefinition = skill.DiceRollDefinition,
|
||||
SkillGroupId = skill.SkillGroupId?.ToString() ?? string.Empty,
|
||||
WildDice = skill.WildDice,
|
||||
AllowFumble = skill.AllowFumble
|
||||
AllowFumble = skill.AllowFumble,
|
||||
FumbleRange = skill.FumbleRange
|
||||
};
|
||||
|
||||
EditSkillFormVersion++;
|
||||
@@ -96,9 +104,13 @@ public partial class CharacterPanel
|
||||
private void OpenCreateSkillGroupModal()
|
||||
{
|
||||
SkillGroupState.Model.Name = string.Empty;
|
||||
SkillGroupState.Model.RulesetId = SelectedCampaignRulesetId;
|
||||
SkillGroupState.Model.DiceRollDefinition = string.Empty;
|
||||
SkillGroupState.Model.WildDice = IsD6 ? 1 : 0;
|
||||
SkillGroupState.Model.AllowFumble = IsD6;
|
||||
SkillGroupState.Model.WildDice = IsD6Ruleset ? 1 : 0;
|
||||
SkillGroupState.Model.AllowFumble = IsD6Ruleset;
|
||||
SkillGroupState.Model.FumbleRange = null;
|
||||
if (IsRolemasterRuleset)
|
||||
SkillGroupState.Model.DiceRollDefinition = "d100";
|
||||
SkillGroupState.ResetValidation();
|
||||
ShowCreateSkillGroupModal = true;
|
||||
}
|
||||
@@ -107,9 +119,12 @@ public partial class CharacterPanel
|
||||
{
|
||||
EditingSkillGroupId = skillGroup.Id;
|
||||
SkillGroupState.Model.Name = skillGroup.Name;
|
||||
SkillGroupState.Model.RulesetId = SelectedCampaignRulesetId;
|
||||
SkillGroupState.Model.DiceRollDefinition = skillGroup.DiceRollDefinition;
|
||||
SkillGroupState.Model.WildDice = skillGroup.WildDice;
|
||||
SkillGroupState.Model.AllowFumble = skillGroup.AllowFumble;
|
||||
SkillGroupState.Model.FumbleRange = skillGroup.FumbleRange;
|
||||
NormalizeSkillGroupFumbleRange();
|
||||
SkillGroupState.ResetValidation();
|
||||
ShowEditSkillGroupModal = true;
|
||||
}
|
||||
@@ -132,9 +147,25 @@ public partial class CharacterPanel
|
||||
if (string.IsNullOrWhiteSpace(SkillGroupState.Model.DiceRollDefinition))
|
||||
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.";
|
||||
|
||||
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)
|
||||
SkillGroupState.Errors["character"] = "Select a character first.";
|
||||
|
||||
@@ -155,7 +186,8 @@ public partial class CharacterPanel
|
||||
SkillGroupState.Model.Name.Trim(),
|
||||
SkillGroupState.Model.DiceRollDefinition.Trim(),
|
||||
SkillGroupState.Model.WildDice,
|
||||
SkillGroupState.Model.AllowFumble));
|
||||
SkillGroupState.Model.AllowFumble,
|
||||
SkillGroupState.Model.FumbleRange));
|
||||
CloseSkillGroupModals();
|
||||
await SkillGroupCreated.InvokeAsync(createdGroup.Id);
|
||||
}
|
||||
@@ -179,9 +211,25 @@ public partial class CharacterPanel
|
||||
if (string.IsNullOrWhiteSpace(SkillGroupState.Model.DiceRollDefinition))
|
||||
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.";
|
||||
|
||||
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)
|
||||
SkillGroupState.Errors["group"] = "Select a skill group first.";
|
||||
|
||||
@@ -202,7 +250,8 @@ public partial class CharacterPanel
|
||||
SkillGroupState.Model.Name.Trim(),
|
||||
SkillGroupState.Model.DiceRollDefinition.Trim(),
|
||||
SkillGroupState.Model.WildDice,
|
||||
SkillGroupState.Model.AllowFumble));
|
||||
SkillGroupState.Model.AllowFumble,
|
||||
SkillGroupState.Model.FumbleRange));
|
||||
CloseSkillGroupModals();
|
||||
await SkillGroupUpdated.InvokeAsync(updatedGroup.Id);
|
||||
}
|
||||
@@ -264,6 +313,37 @@ public partial class CharacterPanel
|
||||
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 ShowEditSkillModal { get; set; }
|
||||
private bool ShowCreateSkillGroupModal { get; set; }
|
||||
@@ -303,7 +383,7 @@ public partial class CharacterPanel
|
||||
public IReadOnlyList<CharacterSheetSkillGroup> SelectedCharacterSkillGroups { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public bool IsD6 { get; set; }
|
||||
public string SelectedCampaignRulesetId { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string RollVisibility { get; set; } = "public";
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="roll-dice-strip" aria-label="@AriaLabel">
|
||||
@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>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
var classes = new List<string> { "die-chip" };
|
||||
@@ -39,12 +60,39 @@ public partial class RollDiceStrip
|
||||
if (die.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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var labels = new List<string> { $"Roll {die.Roll}" };
|
||||
if (die.Sequence.HasValue)
|
||||
labels.Add($"step {die.Sequence.Value}");
|
||||
|
||||
if (die.Wild)
|
||||
labels.Add("wild");
|
||||
|
||||
@@ -60,6 +108,22 @@ public partial class RollDiceStrip
|
||||
if (die.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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,8 @@
|
||||
<p class="field-error">@skillNameError</p>
|
||||
}
|
||||
<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))
|
||||
{
|
||||
<p class="field-error">@expressionError</p>
|
||||
@@ -32,7 +33,7 @@
|
||||
{
|
||||
<p class="field-error">@skillGroupError</p>
|
||||
}
|
||||
@if (IsD6)
|
||||
@if (IsD6Ruleset)
|
||||
{
|
||||
<label for="@WildDiceInputId">Wild dice</label>
|
||||
<input id="@WildDiceInputId" type="number" min="1" step="1" @bind="FormState.Model.WildDice"/>
|
||||
@@ -44,6 +45,19 @@
|
||||
<label for="@AllowFumbleInputId">Allow fumble</label>
|
||||
<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">
|
||||
<button type="submit" disabled="@(IsMutating || IsSubmitting)">@SubmitLabel</button>
|
||||
<button type="button" class="ghost" @onclick="CancelRequested">Cancel</button>
|
||||
|
||||
@@ -14,10 +14,13 @@ public partial class SkillFormModal
|
||||
return;
|
||||
|
||||
FormState.Model.Name = InitialModel.Name;
|
||||
FormState.Model.RulesetId = RulesetId;
|
||||
FormState.Model.DiceRollDefinition = InitialModel.DiceRollDefinition;
|
||||
FormState.Model.SkillGroupId = InitialModel.SkillGroupId;
|
||||
FormState.Model.WildDice = InitialModel.WildDice;
|
||||
FormState.Model.AllowFumble = InitialModel.AllowFumble;
|
||||
FormState.Model.FumbleRange = InitialModel.FumbleRange;
|
||||
SynchronizeRulesetSpecificFields();
|
||||
FormState.ResetValidation();
|
||||
AppliedFormVersion = FormVersion;
|
||||
PendingNameFocus = AutoFocusName;
|
||||
@@ -42,9 +45,25 @@ public partial class SkillFormModal
|
||||
if (string.IsNullOrWhiteSpace(FormState.Model.DiceRollDefinition))
|
||||
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.";
|
||||
|
||||
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;
|
||||
if (!string.IsNullOrWhiteSpace(FormState.Model.SkillGroupId))
|
||||
{
|
||||
@@ -66,7 +85,7 @@ public partial class SkillFormModal
|
||||
SkillSummary skill;
|
||||
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
|
||||
{
|
||||
@@ -76,7 +95,7 @@ public partial class SkillFormModal
|
||||
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);
|
||||
@@ -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]
|
||||
private RpgRollerApiClient ApiClient { get; set; } = null!;
|
||||
|
||||
@@ -104,7 +162,7 @@ public partial class SkillFormModal
|
||||
public bool Visible { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsD6 { get; set; }
|
||||
public string RulesetId { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string Title { get; set; } = "Skill";
|
||||
@@ -127,6 +185,9 @@ public partial class SkillFormModal
|
||||
[Parameter]
|
||||
public string AllowFumbleInputId { get; set; } = "skill-fumble";
|
||||
|
||||
[Parameter]
|
||||
public string FumbleRangeInputId { get; set; } = "skill-fumble-range";
|
||||
|
||||
[Parameter]
|
||||
public SkillFormModel InitialModel { get; set; } = new();
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
IsMutating="IsMutating"
|
||||
SelectedCharacterSkills="PlaySelectedCharacterSkills"
|
||||
SelectedCharacterSkillGroups="PlaySelectedCharacterSkillGroups"
|
||||
IsD6="IsSelectedCampaignD6"
|
||||
SelectedCampaignRulesetId="@(PlaySelectedCampaign?.RulesetId ?? string.Empty)"
|
||||
RollVisibility="RollVisibility"
|
||||
RollVisibilityChanged="OnRollVisibilityChanged"
|
||||
OwnerLabel="OwnerLabel"
|
||||
|
||||
@@ -920,7 +920,12 @@ public partial class Workspace : IAsyncDisposable
|
||||
private string SkillDefinitionLabel(CharacterSheetSkill skill)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off";
|
||||
return $"{skill.DiceRollDefinition}, wild {skill.WildDice}, {fumbleLabel}";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace RpgRoller.Contracts;
|
||||
|
||||
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 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 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 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);
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ public sealed class RpgRollerDbContext : DbContext
|
||||
entity.Property(x => x.DiceRollDefinition).IsRequired().HasMaxLength(128);
|
||||
entity.Property(x => x.WildDice).IsRequired();
|
||||
entity.Property(x => x.AllowFumble).IsRequired();
|
||||
entity.Property(x => x.FumbleRange).IsRequired(false);
|
||||
entity.HasIndex(x => x.CharacterId);
|
||||
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.WildDice).IsRequired();
|
||||
entity.Property(x => x.AllowFumble).IsRequired();
|
||||
entity.Property(x => x.FumbleRange).IsRequired(false);
|
||||
entity.HasIndex(x => x.CharacterId);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ namespace RpgRoller.Domain;
|
||||
public enum RulesetKind
|
||||
{
|
||||
D6,
|
||||
Dnd5e
|
||||
Dnd5e,
|
||||
Rolemaster
|
||||
}
|
||||
|
||||
public enum RollVisibility
|
||||
@@ -60,6 +61,7 @@ public sealed class SkillGroup
|
||||
public required string DiceRollDefinition { get; set; }
|
||||
public required int WildDice { get; set; }
|
||||
public required bool AllowFumble { get; set; }
|
||||
public int? FumbleRange { get; set; }
|
||||
}
|
||||
|
||||
public sealed class Skill
|
||||
@@ -71,6 +73,7 @@ public sealed class Skill
|
||||
public required string DiceRollDefinition { get; set; }
|
||||
public required int WildDice { get; set; }
|
||||
public required bool AllowFumble { get; set; }
|
||||
public int? FumbleRange { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RollLogEntry
|
||||
@@ -87,4 +90,10 @@ public sealed class RollLogEntry
|
||||
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);
|
||||
|
||||
264
RpgRoller/Migrations/20260402222501_AddRolemasterFumbleRange.Designer.cs
generated
Normal file
264
RpgRoller/Migrations/20260402222501_AddRolemasterFumbleRange.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,9 @@ namespace RpgRoller.Migrations
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("FumbleRange")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
@@ -175,6 +178,9 @@ namespace RpgRoller.Migrations
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("FumbleRange")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
|
||||
@@ -34,10 +34,10 @@ if (!string.IsNullOrWhiteSpace(configuredPathBase))
|
||||
}
|
||||
|
||||
app.UseResponseCompression();
|
||||
app.UseStaticFiles();
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.MapRpgRollerApi();
|
||||
app.MapStaticAssets();
|
||||
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
|
||||
app.Run();
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ public static partial class DiceRules
|
||||
if (string.Equals(rulesetId, "dnd5e", StringComparison.OrdinalIgnoreCase))
|
||||
return RulesetKind.Dnd5e;
|
||||
|
||||
if (string.Equals(rulesetId, "rolemaster", StringComparison.OrdinalIgnoreCase))
|
||||
return RulesetKind.Rolemaster;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -20,9 +23,10 @@ public static partial class DiceRules
|
||||
{
|
||||
return ruleset switch
|
||||
{
|
||||
RulesetKind.D6 => "d6",
|
||||
RulesetKind.Dnd5e => "dnd5e",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(ruleset), ruleset, "Unknown ruleset.")
|
||||
RulesetKind.D6 => "d6",
|
||||
RulesetKind.Dnd5e => "dnd5e",
|
||||
RulesetKind.Rolemaster => "rolemaster",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(ruleset), ruleset, "Unknown ruleset.")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,9 +38,10 @@ public static partial class DiceRules
|
||||
var trimmed = expression.Trim();
|
||||
return ruleset switch
|
||||
{
|
||||
RulesetKind.D6 => ParseD6(trimmed),
|
||||
RulesetKind.Dnd5e => ParseDnd5e(trimmed),
|
||||
_ => ServiceResult<DiceExpression>.Failure("invalid_ruleset", "Unknown ruleset.")
|
||||
RulesetKind.D6 => ParseD6(trimmed),
|
||||
RulesetKind.Dnd5e => ParseDnd5e(trimmed),
|
||||
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)}"));
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
return ServiceResult<bool>.Failure("invalid_expression", $"Dice sides must be between 2 and {MaxSides}.");
|
||||
|
||||
if (modifier < 0 || modifier > MaxModifier)
|
||||
return ServiceResult<bool>.Failure("invalid_expression", $"Modifier must be between 0 and {MaxModifier}.");
|
||||
if (modifier < minModifier || modifier > maxModifier)
|
||||
return ServiceResult<bool>.Failure("invalid_expression", $"Modifier must be between {minModifier} and {maxModifier}.");
|
||||
|
||||
return ServiceResult<bool>.Success(true);
|
||||
}
|
||||
@@ -92,7 +125,12 @@ public static partial class DiceRules
|
||||
|
||||
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)]
|
||||
@@ -101,6 +139,9 @@ public static partial class DiceRules
|
||||
[GeneratedRegex("^(?<count>\\d+)d(?<sides>\\d+)(?:\\+(?<modifier>\\d+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
|
||||
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 MaxSides = 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 =
|
||||
[
|
||||
(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")
|
||||
];
|
||||
}
|
||||
@@ -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))
|
||||
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))
|
||||
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)
|
||||
return ServiceResult<SkillGroupSummary>.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message);
|
||||
|
||||
@@ -527,7 +527,8 @@ public sealed class GameService : IGameService
|
||||
Name = name.Trim(),
|
||||
DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression,
|
||||
WildDice = prototypeValidation.Value.WildDice,
|
||||
AllowFumble = prototypeValidation.Value.AllowFumble
|
||||
AllowFumble = prototypeValidation.Value.AllowFumble,
|
||||
FumbleRange = prototypeValidation.Value.FumbleRange
|
||||
};
|
||||
|
||||
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))
|
||||
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))
|
||||
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)
|
||||
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.WildDice = prototypeValidation.Value.WildDice;
|
||||
group.AllowFumble = prototypeValidation.Value.AllowFumble;
|
||||
group.FumbleRange = prototypeValidation.Value.FumbleRange;
|
||||
TouchCharacterLocked(campaign.Id, character.Id);
|
||||
|
||||
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))
|
||||
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))
|
||||
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)
|
||||
return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message);
|
||||
|
||||
@@ -639,7 +641,8 @@ public sealed class GameService : IGameService
|
||||
Name = name.Trim(),
|
||||
DiceRollDefinition = skillValidation.Value!.CanonicalExpression,
|
||||
WildDice = skillValidation.Value.WildDice,
|
||||
AllowFumble = skillValidation.Value.AllowFumble
|
||||
AllowFumble = skillValidation.Value.AllowFumble,
|
||||
FumbleRange = skillValidation.Value.FumbleRange
|
||||
};
|
||||
|
||||
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))
|
||||
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))
|
||||
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)
|
||||
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.WildDice = skillValidation.Value.WildDice;
|
||||
skill.AllowFumble = skillValidation.Value.AllowFumble;
|
||||
skill.FumbleRange = skillValidation.Value.FumbleRange;
|
||||
skill.SkillGroupId = resolvedSkillGroupId.Value;
|
||||
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);
|
||||
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)
|
||||
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)
|
||||
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 (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)
|
||||
{
|
||||
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)
|
||||
@@ -926,6 +973,51 @@ public sealed class GameService : IGameService
|
||||
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)
|
||||
{
|
||||
var initialDice = expression.DiceCount;
|
||||
@@ -1006,14 +1098,80 @@ public sealed class GameService : IGameService
|
||||
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)
|
||||
{
|
||||
var dicePart = string.Join("+", diceValues);
|
||||
if (string.IsNullOrWhiteSpace(dicePart))
|
||||
dicePart = "0";
|
||||
|
||||
var modifierPart = modifier > 0 ? $"+{modifier}" : string.Empty;
|
||||
return $"{dicePart}{modifierPart}={total}";
|
||||
return BuildModifierBreakdown(dicePart, modifier, 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)
|
||||
@@ -1139,22 +1297,22 @@ public sealed class GameService : IGameService
|
||||
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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)
|
||||
@@ -1213,6 +1371,9 @@ public sealed class GameService : IGameService
|
||||
if (dice.Count == 0)
|
||||
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()));
|
||||
if (dice.Count > 3)
|
||||
preview = $"{preview}, ...";
|
||||
@@ -1230,6 +1391,48 @@ public sealed class GameService : IGameService
|
||||
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)
|
||||
{
|
||||
return CanViewCampaignLocked(user.Id, campaign.Id) &&
|
||||
@@ -1658,7 +1861,8 @@ public sealed class GameService : IGameService
|
||||
Name = skill.Name,
|
||||
DiceRollDefinition = skill.DiceRollDefinition,
|
||||
WildDice = skill.WildDice,
|
||||
AllowFumble = skill.AllowFumble
|
||||
AllowFumble = skill.AllowFumble,
|
||||
FumbleRange = skill.FumbleRange
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1671,7 +1875,8 @@ public sealed class GameService : IGameService
|
||||
Name = skillGroup.Name,
|
||||
DiceRollDefinition = skillGroup.DiceRollDefinition,
|
||||
WildDice = skillGroup.WildDice,
|
||||
AllowFumble = skillGroup.AllowFumble
|
||||
AllowFumble = skillGroup.AllowFumble,
|
||||
FumbleRange = skillGroup.FumbleRange
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,11 +28,11 @@ public interface IGameService
|
||||
ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId);
|
||||
ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken);
|
||||
|
||||
ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble);
|
||||
ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, 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, int? fumbleRange = null);
|
||||
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> UpdateSkill(string sessionToken, Guid skillId, 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, int? fumbleRange = null);
|
||||
ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId);
|
||||
ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId);
|
||||
|
||||
|
||||
@@ -222,6 +222,12 @@ select:focus-visible {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.field-help {
|
||||
margin: -0.1rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -528,15 +534,16 @@ select:focus-visible {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.1rem;
|
||||
min-width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
padding-top: 4px;
|
||||
padding: 0.2rem 0.45rem 0;
|
||||
border: 2px solid #2a2418;
|
||||
border-radius: 0.45rem;
|
||||
background: #ffffff;
|
||||
color: #1f1a13;
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.die-chip.wild {
|
||||
@@ -565,6 +572,36 @@ select:focus-visible {
|
||||
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,
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
|
||||
287
TASKS.md
287
TASKS.md
@@ -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.
|
||||
@@ -900,6 +900,16 @@
|
||||
},
|
||||
"allowFumble": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"skillGroupId": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"nullable": true
|
||||
},
|
||||
"fumbleRange": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -924,6 +934,16 @@
|
||||
},
|
||||
"allowFumble": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"skillGroupId": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"nullable": true
|
||||
},
|
||||
"fumbleRange": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -944,6 +964,11 @@
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"skillGroupId": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"nullable": true
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -956,6 +981,11 @@
|
||||
},
|
||||
"allowFumble": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"fumbleRange": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -999,6 +1029,20 @@
|
||||
},
|
||||
"added": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sequence": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"nullable": true
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"signedContribution": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
76
package-lock.json
generated
Normal file
76
package-lock.json
generated
Normal 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
12
package.json
Normal 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
13
playwright.config.js
Normal 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"
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
param(
|
||||
[switch]$SkipDotnetRestore,
|
||||
[switch]$SkipBuild
|
||||
[switch]$SkipBuild,
|
||||
[switch]$SkipPlaywright
|
||||
)
|
||||
|
||||
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 {
|
||||
if ($SkipBuild) {
|
||||
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
|
||||
}
|
||||
|
||||
if (-not $SkipPlaywright) {
|
||||
Invoke-Step -Name "Run Playwright smoke test" -Action {
|
||||
pwsh ./scripts/run-playwright.ps1
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "CI checks passed."
|
||||
}
|
||||
finally {
|
||||
|
||||
61
scripts/run-playwright.ps1
Normal file
61
scripts/run-playwright.ps1
Normal 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
134
tests/e2e/smoke.spec.js
Normal 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();
|
||||
});
|
||||
Reference in New Issue
Block a user