Compare commits
10 Commits
5c62fb5bbb
...
3e1d3746dd
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e1d3746dd | |||
| 368a9a4960 | |||
| 9e91fb2719 | |||
| 8d59868392 | |||
| d4e72fe5bb | |||
| 2997247eeb | |||
| 0c638e8ebe | |||
| 0dff8a275c | |||
| d38003a77c | |||
| f63c3f8f28 |
@@ -10,7 +10,7 @@ Also see the other related technical documentation: README.md.
|
||||
- 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 `jb cleanupcode --build=False $file1;$file2;...` for every file you touched.
|
||||
- After every iteration, run `jb cleanupcode --build=False '$file1' '$file2'` for every file you touched.
|
||||
- After every iteration, run "scripts/ci-local.ps1" and ensure that nothing broke.
|
||||
- After every iteration, update all related documentation according to the change.
|
||||
- Update the wording of touched concerns instead of introducing incremental change reports
|
||||
@@ -34,7 +34,7 @@ Also see the other related technical documentation: README.md.
|
||||
- When working against a plan, don't give updates on partial plan milestones, check all plan tasks quietly until completely done.
|
||||
|
||||
### Reasoning
|
||||
- Feel free to speak to yourself in whatever verbosity and language suits you best.
|
||||
- Feel free to speak to yourself in whatever language suits you best, focusing on concise, compact and essential information exchange.
|
||||
|
||||
## ExecPlans
|
||||
|
||||
|
||||
@@ -80,8 +80,12 @@ Rolemaster support:
|
||||
- Standard expressions such as `d10`, `15d10`, `2d10+48`, and `d100-15`
|
||||
- Open-ended percentile expressions such as `d100!+85`
|
||||
- Conditional `FumbleRange` handling for open-ended percentile skills and skill-group defaults
|
||||
- Persisted and validated automatic retry toggle for open-ended percentile skills; only eligible Rolemaster skills can enable it
|
||||
- Rolemaster skill rolls open a modal prompt before rolling so the player can apply a one-shot situational modifier; the prompt autofocuses, supports Enter and Escape, and closes when clicking outside it
|
||||
- One-shot situational modifiers are transient Rolemaster-only roll inputs; the temporary modifier is applied to both the first attempt and any automatic retry attempt
|
||||
- Automatic retry windows for eligible open-ended skills: results `76-90` retry once with `+5`, and results `91-110` retry once with `+10`
|
||||
- Open-ended high chaining and low-end subtraction with ordered die metadata in roll detail
|
||||
- Compact log badges and summaries for open-ended/fumble-related events
|
||||
- Compact log badges and summaries for open-ended, retry, and fumble-related events, including `Retry +5` and `Retry +10`
|
||||
|
||||
## Local Development
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RolemasterSkillDefinitions_RoundTripFumbleRangeThroughApi()
|
||||
public async Task RolemasterSkillDefinitions_RoundTripRetryAndFumbleOptionsThroughApi()
|
||||
{
|
||||
using var factory = CreateFactory(88, 42, 17);
|
||||
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
@@ -93,15 +93,22 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
|
||||
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 invalidRetry = await gmClient.PostAsJsonAsync($"/api/characters/{character.Id}/skills", new CreateSkillRequest("Bad Retry", "d100+35", 0, false, group.Id, null, true));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, invalidRetry.StatusCode);
|
||||
|
||||
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(gmClient, $"/api/skills/{skill.Id}", new("Awareness", "d100!+45", 0, false, group.Id, 4));
|
||||
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(gmClient, $"/api/characters/{character.Id}/skills", new("Awareness", "d100!+35", 0, false, group.Id, 3, true));
|
||||
Assert.Equal(3, skill.FumbleRange);
|
||||
Assert.True(skill.RolemasterAutoRetry);
|
||||
|
||||
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(gmClient, $"/api/skills/{skill.Id}", new("Awareness", "d100!+45", 0, false, group.Id, 4, true));
|
||||
Assert.Equal(4, updatedSkill.FumbleRange);
|
||||
Assert.True(updatedSkill.RolemasterAutoRetry);
|
||||
|
||||
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);
|
||||
var sheetSkill = Assert.Single(sheet.Skills);
|
||||
Assert.Equal(4, sheetSkill.FumbleRange);
|
||||
Assert.True(sheetSkill.RolemasterAutoRetry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -81,4 +81,105 @@ public sealed class RolemasterApiTests(WebApplicationFactory<Program> factory) :
|
||||
Assert.Equal(-12, die.SignedContribution);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RolemasterAutoRetryRolls_AppearInLogPageAndDetail()
|
||||
{
|
||||
using var factory = CreateFactory(66, 42, 90, 32, 65);
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(client, "rolemaster-retry-api", "Password123", "Rolemaster Retry Api");
|
||||
await LoginAsync(client, "rolemaster-retry-api", "Password123");
|
||||
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Rolemaster Retry", "rolemaster"));
|
||||
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(client, "/api/characters", new("Hero", campaign.Id));
|
||||
var retryFiveSkill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Awareness +5", "d100!+10", 0, false, null, 5, true));
|
||||
var retryTenSkill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Awareness +10", "d100!+1", 0, false, null, 5, true));
|
||||
var disabledSkill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Awareness Off", "d100!+10", 0, false, null, 5));
|
||||
|
||||
var retryFiveRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{retryFiveSkill.Id}/roll", new("public"));
|
||||
var retryTenRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{retryTenSkill.Id}/roll", new("public"));
|
||||
var disabledRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{disabledSkill.Id}/roll", new("public"));
|
||||
var logPage = await GetAsync<CampaignLogPage>(client, $"/api/campaigns/{campaign.Id}/log/page?limit=10");
|
||||
var detail = await GetAsync<CampaignRollDetail>(client, $"/api/rolls/{retryFiveRoll.RollId}");
|
||||
|
||||
Assert.Equal(57, retryFiveRoll.Result);
|
||||
Assert.Equal("66+10=76; retry(+5): 42+10=52; final=57", retryFiveRoll.Breakdown);
|
||||
Assert.Collection(retryFiveRoll.Dice, die =>
|
||||
{
|
||||
Assert.Equal(1, die.Attempt);
|
||||
Assert.Equal(1, die.Sequence);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(2, die.Attempt);
|
||||
Assert.Equal(1, die.Sequence);
|
||||
});
|
||||
|
||||
Assert.Equal(43, retryTenRoll.Result);
|
||||
Assert.Equal("90+1=91; retry(+10): 32+1=33; final=43", retryTenRoll.Breakdown);
|
||||
|
||||
Assert.Equal(75, disabledRoll.Result);
|
||||
Assert.Equal("65+10=75", disabledRoll.Breakdown);
|
||||
Assert.All(disabledRoll.Dice, die => Assert.Null(die.Attempt));
|
||||
|
||||
Assert.Equal(3, logPage.Entries.Length);
|
||||
Assert.Equal("66 | open-ended | retry +5", logPage.Entries[0].SummaryText);
|
||||
Assert.Equal(["r66", "rs5"], Assert.IsType<string[]>(logPage.Entries[0].EventBadges));
|
||||
Assert.Equal("90 | open-ended | retry +10", logPage.Entries[1].SummaryText);
|
||||
Assert.Equal(["rs10"], Assert.IsType<string[]>(logPage.Entries[1].EventBadges));
|
||||
Assert.Equal("65 | open-ended", logPage.Entries[2].SummaryText);
|
||||
Assert.Null(logPage.Entries[2].EventBadges);
|
||||
|
||||
Assert.Equal(retryFiveRoll.Breakdown, detail.Breakdown);
|
||||
Assert.Collection(detail.Dice, die =>
|
||||
{
|
||||
Assert.Equal(1, die.Attempt);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(2, die.Attempt);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RolemasterSkillRoll_AcceptsSituationalModifier_AndAppliesItToRetryMath()
|
||||
{
|
||||
using var factory = CreateFactory(8, 42);
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(client, "rolemaster-situational-api", "Password123", "Rolemaster Situational Api");
|
||||
await LoginAsync(client, "rolemaster-situational-api", "Password123");
|
||||
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Rolemaster Situational", "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("Observation", "d100!+50", 0, false, null, 5, true));
|
||||
|
||||
var roll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{skill.Id}/roll", new("public", 20));
|
||||
|
||||
Assert.Equal(117, roll.Result);
|
||||
Assert.Equal("8+50+20=78; retry(+5): 42+50+20=112; final=117", roll.Breakdown);
|
||||
Assert.Collection(roll.Dice, die => Assert.Equal(1, die.Attempt), die => Assert.Equal(2, die.Attempt));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkillRoll_RejectsSituationalModifier_ForNonRolemasterCampaigns()
|
||||
{
|
||||
using var factory = CreateFactory(12);
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(client, "non-rolemaster-situational-api", "Password123", "Non Rolemaster Situational Api");
|
||||
await LoginAsync(client, "non-rolemaster-situational-api", "Password123");
|
||||
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Dnd Situational", "dnd5e"));
|
||||
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("Attack", "1d20+5", 0, false));
|
||||
|
||||
var response = await client.PostAsJsonAsync($"/api/skills/{skill.Id}/roll", new RollSkillRequest("public", 20));
|
||||
var error = await response.Content.ReadFromJsonAsync<ApiError>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("invalid_situational_modifier", error.Code);
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,7 @@ public sealed class HostingCoverageTests
|
||||
Assert.Contains("WildDice", columns);
|
||||
Assert.Contains("AllowFumble", columns);
|
||||
Assert.Contains("FumbleRange", columns);
|
||||
Assert.Contains("RolemasterAutoRetry", columns);
|
||||
|
||||
using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand();
|
||||
skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');";
|
||||
@@ -208,6 +209,11 @@ public sealed class HostingCoverageTests
|
||||
rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';";
|
||||
var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, rolemasterHistoryCount);
|
||||
|
||||
using var retryHistoryCommand = verifyConnection.CreateCommand();
|
||||
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
|
||||
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, retryHistoryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -348,6 +354,11 @@ public sealed class HostingCoverageTests
|
||||
rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';";
|
||||
var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, rolemasterHistoryCount);
|
||||
|
||||
using var retryHistoryCommand = verifyConnection.CreateCommand();
|
||||
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
|
||||
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, retryHistoryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -459,6 +470,7 @@ public sealed class HostingCoverageTests
|
||||
skillColumns.Add(skillsTableInfoReader.GetString(1));
|
||||
|
||||
Assert.Contains("FumbleRange", skillColumns);
|
||||
Assert.Contains("RolemasterAutoRetry", skillColumns);
|
||||
|
||||
using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand();
|
||||
skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');";
|
||||
@@ -473,5 +485,10 @@ public sealed class HostingCoverageTests
|
||||
authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';";
|
||||
var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, authorizationRolesHistoryCount);
|
||||
|
||||
using var retryHistoryCommand = verifyConnection.CreateCommand();
|
||||
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
|
||||
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, retryHistoryCount);
|
||||
}
|
||||
}
|
||||
@@ -130,9 +130,9 @@ public sealed class PayloadBudgetTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RolemasterRollDetailPayload_StaysWithinBudget_AndRolemasterMetadataRemainsLazy()
|
||||
public void RolemasterRollDetailPayload_StaysWithinBudget_AndRetryMetadataRemainsLazy()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(96, 100, 100, 100, 100, 97, 12);
|
||||
using var harness = ServiceTestSupport.CreateHarness(66, 42);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-rm-detail-budget", "Password123", "GM");
|
||||
@@ -143,11 +143,12 @@ public sealed class PayloadBudgetTests
|
||||
|
||||
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 skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Sense", "d100!+10", 0, false, null, 5, true));
|
||||
|
||||
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));
|
||||
Assert.Equal("66 | open-ended | retry +5", Assert.Single(logPage.Entries).SummaryText);
|
||||
|
||||
AssertPayloadWithinBudget(detail, 4 * 1024, "rolemaster roll detail");
|
||||
|
||||
@@ -160,8 +161,9 @@ public sealed class PayloadBudgetTests
|
||||
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);
|
||||
Assert.Contains("\"signedContribution\":66", detailJson, StringComparison.Ordinal);
|
||||
Assert.Contains("\"attempt\":1", detailJson, StringComparison.Ordinal);
|
||||
Assert.Contains("\"attempt\":2", detailJson, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static void AssertPayloadWithinBudget<T>(T payload, int maxBytes, string label)
|
||||
|
||||
@@ -48,20 +48,38 @@ public sealed class ServiceHelperExtractionTests
|
||||
public void SkillDefinitionValidator_ValidatesRulesetSpecificOptions()
|
||||
{
|
||||
var d6 = SkillDefinitionValidator.Validate(RulesetKind.D6, "2D+1", 1, true, null);
|
||||
var rolemaster = SkillDefinitionValidator.Validate(RulesetKind.Rolemaster, "d100!+15", 0, false, 5);
|
||||
var rolemaster = SkillDefinitionValidator.Validate(RulesetKind.Rolemaster, "d100!+15", 0, false, 5, true);
|
||||
var invalidD6 = SkillDefinitionValidator.Validate(RulesetKind.D6, "2D+1", 0, true, null);
|
||||
var invalidRolemaster = SkillDefinitionValidator.Validate(RulesetKind.Rolemaster, "d100!+15", 0, false, null);
|
||||
var invalidRetry = SkillDefinitionValidator.Validate(RulesetKind.Rolemaster, "d100+15", 0, false, null, true);
|
||||
|
||||
Assert.True(d6.Succeeded);
|
||||
Assert.Equal(("2D+1", 1, true, (int?)null), d6.Value);
|
||||
Assert.Equal(("2D+1", 1, true, (int?)null, false), d6.Value);
|
||||
|
||||
Assert.True(rolemaster.Succeeded);
|
||||
Assert.Equal(("d100!+15", 0, false, (int?)5), rolemaster.Value);
|
||||
Assert.Equal(("d100!+15", 0, false, (int?)5, true), rolemaster.Value);
|
||||
|
||||
Assert.False(invalidD6.Succeeded);
|
||||
Assert.Equal("invalid_wild_dice", invalidD6.Error!.Code);
|
||||
|
||||
Assert.False(invalidRolemaster.Succeeded);
|
||||
Assert.Equal("invalid_fumble_range", invalidRolemaster.Error!.Code);
|
||||
|
||||
Assert.False(invalidRetry.Succeeded);
|
||||
Assert.Equal("invalid_rolemaster_retry", invalidRetry.Error!.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RolemasterRetryPolicy_ResolvesRetryBandsAndMarkers()
|
||||
{
|
||||
Assert.Equal(5, RolemasterRetryPolicy.ResolveAutoRetryBonus(76));
|
||||
Assert.Equal(5, RolemasterRetryPolicy.ResolveAutoRetryBonus(90));
|
||||
Assert.Equal(10, RolemasterRetryPolicy.ResolveAutoRetryBonus(91));
|
||||
Assert.Equal(10, RolemasterRetryPolicy.ResolveAutoRetryBonus(110));
|
||||
Assert.Null(RolemasterRetryPolicy.ResolveAutoRetryBonus(75));
|
||||
Assert.Null(RolemasterRetryPolicy.ResolveAutoRetryBonus(111));
|
||||
Assert.Equal(5, RolemasterRetryPolicy.TryExtractRetryBonus("68+10=78; retry(+5): 42+10=52; final=57"));
|
||||
Assert.Equal(10, RolemasterRetryPolicy.TryExtractRetryBonus("90+1=91; retry(+10): 32+1=33; final=43"));
|
||||
Assert.Null(RolemasterRetryPolicy.TryExtractRetryBonus("68+10=78"));
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ public sealed class ServicePersistenceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RolemasterFumbleRange_PersistsAcrossDatabaseReload()
|
||||
public void RolemasterSkillOptions_PersistAcrossDatabaseReload()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness();
|
||||
var service = harness.Service;
|
||||
@@ -108,7 +108,7 @@ public sealed class ServicePersistenceTests
|
||||
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));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Read Runes", "d100!+35", 0, false, group.Id, 3, true));
|
||||
|
||||
using var reloadedHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath);
|
||||
var reloadedSheet = ServiceTestSupport.GetValue(reloadedHarness.Service.GetCharacterSheet(ownerSession, character.Id));
|
||||
@@ -118,5 +118,6 @@ public sealed class ServicePersistenceTests
|
||||
|
||||
var reloadedSkill = Assert.Single(reloadedSheet.Skills, current => current.Id == skill.Id);
|
||||
Assert.Equal(3, reloadedSkill.FumbleRange);
|
||||
Assert.True(reloadedSkill.RolemasterAutoRetry);
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,24 @@ public sealed class ServiceRolemasterRollTests
|
||||
Assert.Equal(73, die.SignedContribution);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterStandardSingleDie_AppliesSituationalModifier()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(73);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-percentile-bonus", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-percentile-bonus", "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", 20));
|
||||
|
||||
Assert.Equal(78, roll.Result);
|
||||
Assert.Equal("73-15+20=78", roll.Breakdown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterOpenEndedHigh_RecursesAndBuildsReadableLogSummary()
|
||||
{
|
||||
@@ -174,4 +192,126 @@ public sealed class ServiceRolemasterRollTests
|
||||
Assert.Equal("r66", badge);
|
||||
Assert.Equal("66 | rolemaster", logEntry.SummaryText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterAutoRetryPlusFive_UsesRetryResultAndMarksAttempts()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(66, 42);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-retry-five", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-retry-five", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster Retry", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Awareness", "d100!+10", 0, false, null, 5, true));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||
var detail = ServiceTestSupport.GetValue(service.GetRollDetail(session, roll.RollId));
|
||||
var logEntry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)).Entries);
|
||||
|
||||
Assert.Equal(57, roll.Result);
|
||||
Assert.Equal("66+10=76; retry(+5): 42+10=52; final=57", roll.Breakdown);
|
||||
Assert.Equal("66 | open-ended | retry +5", logEntry.SummaryText);
|
||||
Assert.Equal(["r66", "rs5"], Assert.IsType<string[]>(logEntry.EventBadges));
|
||||
Assert.Equal(roll.Breakdown, detail.Breakdown);
|
||||
Assert.Collection(detail.Dice, die =>
|
||||
{
|
||||
Assert.Equal(66, die.Roll);
|
||||
Assert.Equal(1, die.Sequence);
|
||||
Assert.Equal(1, die.Attempt);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
|
||||
Assert.Equal(66, die.SignedContribution);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(42, die.Roll);
|
||||
Assert.Equal(1, die.Sequence);
|
||||
Assert.Equal(2, die.Attempt);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
|
||||
Assert.Equal(42, die.SignedContribution);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterAutoRetry_UsesSituationalModifierInBothAttempts()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(8, 42);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-retry-situational", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-retry-situational", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster Retry", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Observation", "d100!+50", 0, false, null, 5, true));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public", 20));
|
||||
var logEntry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)).Entries);
|
||||
|
||||
Assert.Equal(117, roll.Result);
|
||||
Assert.Equal("8+50+20=78; retry(+5): 42+50+20=112; final=117", roll.Breakdown);
|
||||
Assert.Equal("8 | open-ended | retry +5", logEntry.SummaryText);
|
||||
Assert.Equal(["rs5"], Assert.IsType<string[]>(logEntry.EventBadges));
|
||||
Assert.All(roll.Dice, die => Assert.True(die.Attempt is 1 or 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterAutoRetryPlusTen_UsesRetryResultAndMarksAttempts()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(90, 32);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-retry-ten", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-retry-ten", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster Retry", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Awareness", "d100!+1", 0, false, null, 5, true));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||
var logEntry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)).Entries);
|
||||
|
||||
Assert.Equal(43, roll.Result);
|
||||
Assert.Equal("90+1=91; retry(+10): 32+1=33; final=43", roll.Breakdown);
|
||||
Assert.Equal("90 | open-ended | retry +10", logEntry.SummaryText);
|
||||
Assert.Equal(["rs10"], Assert.IsType<string[]>(logEntry.EventBadges));
|
||||
Assert.All(roll.Dice, die => Assert.True(die.Attempt is 1 or 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterAutoRetryDisabled_KeepsOriginalResult()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(65);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-retry-off", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-retry-off", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster Retry", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Awareness", "d100!+10", 0, false, null, 5));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||
var logEntry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)).Entries);
|
||||
|
||||
Assert.Equal(75, roll.Result);
|
||||
Assert.Equal("65+10=75", roll.Breakdown);
|
||||
Assert.Equal("65 | open-ended", logEntry.SummaryText);
|
||||
Assert.Null(logEntry.EventBadges);
|
||||
Assert.All(roll.Dice, die => Assert.Null(die.Attempt));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_SituationalModifier_IsRejectedForNonRolemasterCampaigns()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(12);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-non-rolemaster-modifier", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-non-rolemaster-modifier", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "DnD", "dnd5e"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Attack", "1d20+5", 0, false));
|
||||
|
||||
var roll = service.RollSkill(session, skill.Id, "public", 20);
|
||||
|
||||
Assert.False(roll.Succeeded);
|
||||
Assert.Equal("invalid_situational_modifier", roll.Error!.Code);
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,12 @@ public sealed class ServiceRollHelperTests
|
||||
{
|
||||
Assert.Equal("4+5+2=11", RollBreakdownFormatter.BuildBreakdown([4, 5], 2, 11));
|
||||
Assert.Equal("0=0", RollBreakdownFormatter.BuildBreakdown([], 0, 0));
|
||||
Assert.Equal("4+5+2+7=18", RollBreakdownFormatter.BuildRolemasterModifierBreakdown([4, 5], 2, 7, 18));
|
||||
Assert.Equal("97+96+45+85=323", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(97, [96, 45], false, 85, 323));
|
||||
Assert.Equal("8+50+20=78", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(8, [], false, 50, 78, 20));
|
||||
Assert.Equal("(05) -97 -100 -12 +85 = -124", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(5, [97, 100, 12], true, 85, -124));
|
||||
Assert.Equal("(05) -97 -100 -12 +85 +20 = -104", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(5, [97, 100, 12], true, 85, -104, 20));
|
||||
Assert.Equal("66+10=76; retry(+5): 42+10=52; final=57", RollBreakdownFormatter.BuildRolemasterRetryBreakdown("66+10=76", 5, "42+10=52", 57));
|
||||
Assert.Equal("05", RollBreakdownFormatter.FormatRolemasterTriggerRoll(5));
|
||||
}
|
||||
|
||||
@@ -28,15 +32,19 @@ public sealed class ServiceRollHelperTests
|
||||
{
|
||||
var d6Dice = new[] { new RollDieResult(6, true, false, true, false, false), new RollDieResult(1, false, true, true, false, false) };
|
||||
var rolemasterDice = new[] { new RollDieResult(5, false, false, false, false, false, 1, RollDieKinds.RolemasterOpenEndedInitial), new RollDieResult(97, false, false, false, false, false, 2, RollDieKinds.RolemasterOpenEndedLowSubtract, -97), new RollDieResult(100, false, false, false, false, false, 3, RollDieKinds.RolemasterOpenEndedLowSubtract, -100) };
|
||||
var retryDice = new[] { new RollDieResult(66, false, false, false, false, false, 1, RollDieKinds.RolemasterOpenEndedInitial, 66, 1), new RollDieResult(42, false, false, false, false, false, 1, RollDieKinds.RolemasterOpenEndedInitial, 42, 2) };
|
||||
const string retryBreakdown = "66+10=76; retry(+5): 42+10=52; final=57";
|
||||
|
||||
Assert.Equal("1d20+5", CampaignLogSummaryBuilder.ExtractCustomRollExpression("1d20+5 => 20+5=25", " => "));
|
||||
Assert.Null(CampaignLogSummaryBuilder.ExtractCustomRollExpression("20+5=25", " => "));
|
||||
Assert.Equal("6, 1", CampaignLogSummaryBuilder.BuildCompactLogSummary(d6Dice));
|
||||
Assert.Equal("(05) -97 -100 | open-ended low", CampaignLogSummaryBuilder.BuildCompactLogSummary(rolemasterDice));
|
||||
Assert.Equal("66 | open-ended | retry +5", CampaignLogSummaryBuilder.BuildCompactLogSummary(retryDice, retryBreakdown));
|
||||
Assert.Equal("No detail available.", CampaignLogSummaryBuilder.BuildCompactLogSummary([]));
|
||||
Assert.Equal(["w6", "w1"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.D6, null, d6Dice)));
|
||||
Assert.Equal(["n20"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Dnd5e, "1d20+5", [new(20, false, false, false, false, false)])));
|
||||
Assert.Equal(["rf", "r100"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Rolemaster, "d100!+85", rolemasterDice)));
|
||||
Assert.Equal(["r66", "rs5"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Rolemaster, "d100!+10", retryDice, retryBreakdown)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -324,7 +324,8 @@ public sealed class ServiceSharedHelperTests
|
||||
DiceRollDefinition = "d100!+25",
|
||||
WildDice = 0,
|
||||
AllowFumble = false,
|
||||
FumbleRange = 3
|
||||
FumbleRange = 3,
|
||||
RolemasterAutoRetry = true
|
||||
};
|
||||
store.RebuildCampaignStateLocked();
|
||||
store.TouchRosterLocked(campaignId);
|
||||
@@ -371,6 +372,7 @@ public sealed class ServiceSharedHelperTests
|
||||
Assert.Single(sheet.Skills);
|
||||
Assert.Equal(5, groupSummary.FumbleRange);
|
||||
Assert.Equal(3, skillSummary.FumbleRange);
|
||||
Assert.True(skillSummary.RolemasterAutoRetry);
|
||||
Assert.Equal("private", rollResult.Visibility);
|
||||
Assert.Equal("Owner", logDto.RollerDisplayName);
|
||||
Assert.Equal("private-self", logListDto.VisibilityStyle);
|
||||
|
||||
@@ -224,5 +224,12 @@ public sealed class ServiceSkillGroupAndOwnershipTests
|
||||
Assert.Equal(0, openEndedSkill.WildDice);
|
||||
Assert.False(openEndedSkill.AllowFumble);
|
||||
Assert.Equal(5, openEndedSkill.FumbleRange);
|
||||
|
||||
var invalidRetrySkill = service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "d100+15", 0, false, rolemasterGroup.Id, null, true);
|
||||
Assert.False(invalidRetrySkill.Succeeded);
|
||||
Assert.Equal("invalid_rolemaster_retry", invalidRetrySkill.Error!.Code);
|
||||
|
||||
var retrySkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "d100!+85", 0, false, rolemasterGroup.Id, 5, true));
|
||||
Assert.True(retrySkill.RolemasterAutoRetry);
|
||||
}
|
||||
}
|
||||
@@ -122,12 +122,12 @@ public sealed class WorkspaceQueryServiceTests
|
||||
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)
|
||||
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false)
|
||||
{
|
||||
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)
|
||||
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
@@ -142,7 +142,7 @@ public sealed class WorkspaceQueryServiceTests
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility)
|
||||
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier = 0)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
@@ -27,13 +27,13 @@ public sealed class WorkspaceStateTests
|
||||
[Fact]
|
||||
public void SkillDefinitionLabel_FormatsD6RolemasterAndDefaultRulesets()
|
||||
{
|
||||
var skill = new CharacterSheetSkill(Guid.NewGuid(), null, "Awareness", "d100!+15", 1, true, 5);
|
||||
var skill = new CharacterSheetSkill(Guid.NewGuid(), null, "Awareness", "d100!+15", 1, true, 5, true);
|
||||
var state = new WorkspaceState { SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), []) };
|
||||
|
||||
Assert.Equal("d100!+15, wild 1, fumble on", state.SkillDefinitionLabel(skill));
|
||||
|
||||
state.SelectedCampaign = new(Guid.NewGuid(), "Alpha", "rolemaster", new(Guid.NewGuid(), "GM"), []);
|
||||
Assert.Equal("Open-ended percentile: d100!+15, fumble <= 5", state.SkillDefinitionLabel(skill));
|
||||
Assert.Equal("Open-ended percentile: d100!+15, fumble <= 5, auto retry", state.SkillDefinitionLabel(skill));
|
||||
|
||||
state.SelectedCampaign = new(Guid.NewGuid(), "Alpha", "dnd5e", new(Guid.NewGuid(), "GM"), []);
|
||||
Assert.Equal("d100!+15", state.SkillDefinitionLabel(skill));
|
||||
@@ -52,7 +52,7 @@ public sealed class WorkspaceStateTests
|
||||
SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), [ownedCharacter, secondOwnedCharacter, otherCharacter]),
|
||||
SelectedCharacterId = secondOwnedCharacter.Id,
|
||||
ActiveCharacterId = ownedCharacter.Id,
|
||||
SelectedCharacterSkills = [new(Guid.NewGuid(), null, "Stealth", "2D+1", 1, true, null)],
|
||||
SelectedCharacterSkills = [new(Guid.NewGuid(), null, "Stealth", "2D+1", 1, true, null, false)],
|
||||
SelectedCharacterSkillGroups = [new(Guid.NewGuid(), "Combat", "2D+1", 1, true, null)]
|
||||
};
|
||||
|
||||
|
||||
@@ -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, request.FumbleRange);
|
||||
var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId, request.FumbleRange, request.RolemasterAutoRetry);
|
||||
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, request.FumbleRange);
|
||||
var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId, request.FumbleRange, request.RolemasterAutoRetry);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ internal static class SkillEndpoints
|
||||
|
||||
group.MapPost("/skills/{skillId:guid}/roll", (Guid skillId, RollSkillRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.RollSkill(context.GetRequiredSessionToken(), skillId, request.Visibility);
|
||||
var result = game.RollSkill(context.GetRequiredSessionToken(), skillId, request.Visibility, request.SituationalModifier);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ public sealed class SkillFormModel
|
||||
public int WildDice { get; set; }
|
||||
public bool AllowFumble { get; set; }
|
||||
public int? FumbleRange { get; set; }
|
||||
public bool RolemasterAutoRetry { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SkillGroupFormModel
|
||||
|
||||
@@ -112,6 +112,8 @@ public partial class CampaignLogPanel
|
||||
"rf" => new("Fumble", "danger"),
|
||||
"r100" => new("100", "rare"),
|
||||
"r66" => new("66", "rare"),
|
||||
"rs5" => new("Retry +5", "rare"),
|
||||
"rs10" => new("Retry +10", "rare"),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ public partial class CharacterPanel
|
||||
SkillGroupId = selectedGroup?.Id.ToString() ?? string.Empty,
|
||||
WildDice = selectedGroup?.WildDice ?? (IsD6Ruleset ? 1 : 0),
|
||||
AllowFumble = selectedGroup?.AllowFumble ?? IsD6Ruleset,
|
||||
FumbleRange = selectedGroup?.FumbleRange
|
||||
FumbleRange = selectedGroup?.FumbleRange,
|
||||
RolemasterAutoRetry = false
|
||||
};
|
||||
|
||||
if (IsRolemasterRuleset && string.IsNullOrWhiteSpace(CreateSkillInitialModel.DiceRollDefinition))
|
||||
@@ -40,7 +41,8 @@ public partial class CharacterPanel
|
||||
SkillGroupId = skill.SkillGroupId?.ToString() ?? string.Empty,
|
||||
WildDice = skill.WildDice,
|
||||
AllowFumble = skill.AllowFumble,
|
||||
FumbleRange = skill.FumbleRange
|
||||
FumbleRange = skill.FumbleRange,
|
||||
RolemasterAutoRetry = skill.RolemasterAutoRetry
|
||||
};
|
||||
|
||||
EditSkillFormVersion++;
|
||||
@@ -72,9 +74,9 @@ public partial class CharacterPanel
|
||||
await RollVisibilityChanged.InvokeAsync(selectedVisibility);
|
||||
}
|
||||
|
||||
private async Task RollSkillAsync(Guid skillId)
|
||||
private async Task RollSkillAsync(CharacterSheetSkill skill)
|
||||
{
|
||||
await RollRequested.InvokeAsync(skillId);
|
||||
await RollRequested.InvokeAsync(skill);
|
||||
}
|
||||
|
||||
private Task OnAddSkillRequestedAsync(Guid? skillGroupId)
|
||||
@@ -406,5 +408,5 @@ public partial class CharacterPanel
|
||||
public EventCallback<string> ErrorOccurred { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> RollRequested { get; set; }
|
||||
public EventCallback<CharacterSheetSkill> RollRequested { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="modal-overlay" role="presentation" @onclick="HandleOverlayClickAsync">
|
||||
<section class="modal-card rolemaster-roll-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Rolemaster situational modifier"
|
||||
tabindex="-1"
|
||||
@onclick:stopPropagation="true"
|
||||
@onkeydown="HandleKeyDownAsync">
|
||||
<h2>Rolemaster skill roll</h2>
|
||||
<p class="muted">Roll <strong>@SkillName</strong> using <code>@Expression</code>.</p>
|
||||
@if (!string.IsNullOrWhiteSpace(ErrorMessage))
|
||||
{
|
||||
<p class="form-error">@ErrorMessage</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="SubmitAsync" @onsubmit:preventDefault>
|
||||
<label for="@ModifierInputId">Situational modifier</label>
|
||||
<input id="@ModifierInputId"
|
||||
@ref="ModifierInputElement"
|
||||
value="@CurrentModifierText"
|
||||
@oninput="OnModifierInput"
|
||||
placeholder="Blank = 0"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"/>
|
||||
<p class="field-help">Optional one-shot bonus or penalty. Examples: <code>20</code>, <code>-15</code>, or blank for <code>0</code>.</p>
|
||||
<div class="inline-actions">
|
||||
<button type="submit" disabled="@(IsMutating || IsSubmitting)">Roll</button>
|
||||
<button type="button" class="ghost" disabled="@(IsMutating || IsSubmitting)" @onclick="CancelRequested">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
|
||||
namespace RpgRoller.Components.Pages.HomeControls;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public partial class RolemasterSkillRollModal
|
||||
{
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
CurrentModifierText = ModifierText;
|
||||
if (!Visible || WasVisible)
|
||||
{
|
||||
WasVisible = Visible;
|
||||
return;
|
||||
}
|
||||
|
||||
PendingFocus = true;
|
||||
WasVisible = true;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!Visible || !PendingFocus)
|
||||
return;
|
||||
|
||||
PendingFocus = false;
|
||||
await ModifierInputElement.FocusAsync();
|
||||
}
|
||||
|
||||
private Task OnModifierInput(ChangeEventArgs args)
|
||||
{
|
||||
CurrentModifierText = args.Value?.ToString() ?? string.Empty;
|
||||
return ModifierTextChanged.InvokeAsync(CurrentModifierText);
|
||||
}
|
||||
|
||||
private Task SubmitAsync()
|
||||
{
|
||||
return ConfirmRequested.InvokeAsync(CurrentModifierText);
|
||||
}
|
||||
|
||||
private Task HandleOverlayClickAsync()
|
||||
{
|
||||
if (IsMutating || IsSubmitting)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return CancelRequested.InvokeAsync();
|
||||
}
|
||||
|
||||
private Task HandleKeyDownAsync(KeyboardEventArgs args)
|
||||
{
|
||||
if ((IsMutating || IsSubmitting) || !string.Equals(args.Key, "Escape", StringComparison.Ordinal))
|
||||
return Task.CompletedTask;
|
||||
|
||||
return CancelRequested.InvokeAsync();
|
||||
}
|
||||
|
||||
private bool PendingFocus { get; set; }
|
||||
private bool WasVisible { get; set; }
|
||||
private string CurrentModifierText { get; set; } = string.Empty;
|
||||
private ElementReference ModifierInputElement { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool Visible { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string SkillName { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string Expression { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string ModifierText { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<string> ModifierTextChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsMutating { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsSubmitting { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string ModifierInputId { get; set; } = "rolemaster-situational-modifier";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<string> ConfirmRequested { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback CancelRequested { get; set; }
|
||||
}
|
||||
@@ -90,6 +90,9 @@ public partial class RollDiceStrip
|
||||
if (die.Sequence.HasValue)
|
||||
labels.Add($"step {die.Sequence.Value}");
|
||||
|
||||
if (die.Attempt.HasValue)
|
||||
labels.Add(die.Attempt.Value == 1 ? "attempt 1" : $"retry attempt {die.Attempt.Value}");
|
||||
|
||||
if (die.Wild)
|
||||
labels.Add("wild");
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ internal static class RulesetFormHelpers
|
||||
return parseResult.Succeeded && parseResult.Value is not null && parseResult.Value.Kind == DiceExpressionKind.RolemasterOpenEndedPercentile;
|
||||
}
|
||||
|
||||
public static string DescribeRolemasterExpression(string expression, int? fumbleRange)
|
||||
public static string DescribeRolemasterExpression(string expression, int? fumbleRange, bool rolemasterAutoRetry = false)
|
||||
{
|
||||
var parseResult = TryParseRolemasterExpression(expression);
|
||||
if (!parseResult.Succeeded || parseResult.Value is null)
|
||||
@@ -38,7 +38,7 @@ internal static class RulesetFormHelpers
|
||||
|
||||
return parseResult.Value.Kind switch
|
||||
{
|
||||
DiceExpressionKind.RolemasterOpenEndedPercentile => fumbleRange.HasValue ? $"Open-ended percentile: {parseResult.Value.Canonical}, fumble <= {fumbleRange.Value}" : $"Open-ended percentile: {parseResult.Value.Canonical}",
|
||||
DiceExpressionKind.RolemasterOpenEndedPercentile => DescribeOpenEndedExpression(parseResult.Value.Canonical, fumbleRange, rolemasterAutoRetry),
|
||||
_ => $"Rolemaster: {parseResult.Value.Canonical}"
|
||||
};
|
||||
}
|
||||
@@ -55,4 +55,17 @@ internal static class RulesetFormHelpers
|
||||
|
||||
return DiceRules.ParseExpression(RulesetKind.Rolemaster, expression);
|
||||
}
|
||||
|
||||
private static string DescribeOpenEndedExpression(string canonicalExpression, int? fumbleRange, bool rolemasterAutoRetry)
|
||||
{
|
||||
var parts = new List<string> { $"Open-ended percentile: {canonicalExpression}" };
|
||||
|
||||
if (fumbleRange.HasValue)
|
||||
parts.Add($"fumble <= {fumbleRange.Value}");
|
||||
|
||||
if (rolemasterAutoRetry)
|
||||
parts.Add("auto retry");
|
||||
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,10 @@
|
||||
{
|
||||
<p class="field-error">@fumbleRangeError</p>
|
||||
}
|
||||
|
||||
<label for="skill-auto-retry">Automatic retry</label>
|
||||
<input id="skill-auto-retry" type="checkbox" @bind="FormState.Model.RolemasterAutoRetry"/>
|
||||
<p class="field-help">When later enabled in rolling, retry bands are 76-90 and 91-110.</p>
|
||||
}
|
||||
}
|
||||
<div class="inline-actions">
|
||||
|
||||
@@ -19,6 +19,7 @@ public partial class SkillFormModal
|
||||
FormState.Model.WildDice = InitialModel.WildDice;
|
||||
FormState.Model.AllowFumble = InitialModel.AllowFumble;
|
||||
FormState.Model.FumbleRange = InitialModel.FumbleRange;
|
||||
FormState.Model.RolemasterAutoRetry = InitialModel.RolemasterAutoRetry;
|
||||
SynchronizeRulesetSpecificFields();
|
||||
FormState.ResetValidation();
|
||||
AppliedFormVersion = FormVersion;
|
||||
@@ -53,7 +54,10 @@ public partial class SkillFormModal
|
||||
FormState.Errors["fumbleRange"] = "Open-ended Rolemaster skills require a fumble range.";
|
||||
}
|
||||
else
|
||||
{
|
||||
FormState.Model.FumbleRange = null;
|
||||
FormState.Model.RolemasterAutoRetry = false;
|
||||
}
|
||||
|
||||
if (!IsD6Ruleset)
|
||||
{
|
||||
@@ -81,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, FormState.Model.FumbleRange));
|
||||
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, FormState.Model.RolemasterAutoRetry));
|
||||
else
|
||||
{
|
||||
if (!SelectedCharacterId.HasValue)
|
||||
@@ -90,7 +94,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, FormState.Model.FumbleRange));
|
||||
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, FormState.Model.RolemasterAutoRetry));
|
||||
}
|
||||
|
||||
await SkillSaved.InvokeAsync(skill.Id);
|
||||
@@ -115,7 +119,10 @@ public partial class SkillFormModal
|
||||
private void SynchronizeRulesetSpecificFields()
|
||||
{
|
||||
if (!IsRolemasterRuleset)
|
||||
{
|
||||
FormState.Model.RolemasterAutoRetry = false;
|
||||
return;
|
||||
}
|
||||
|
||||
NormalizeRolemasterFumbleRange();
|
||||
}
|
||||
@@ -135,6 +142,7 @@ public partial class SkillFormModal
|
||||
}
|
||||
|
||||
FormState.Model.FumbleRange = null;
|
||||
FormState.Model.RolemasterAutoRetry = false;
|
||||
}
|
||||
|
||||
private bool IsD6Ruleset => RulesetFormHelpers.IsD6(RulesetId);
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
class="chip-button"
|
||||
title="Roll skill"
|
||||
disabled="@(IsMutating)"
|
||||
@onclick="() => RollSkillRequested.InvokeAsync(skill.Id)">
|
||||
@onclick="() => RollSkillRequested.InvokeAsync(skill)">
|
||||
<span aria-hidden="true" class="emoji">🎲</span>
|
||||
<span class="sr-only">Roll @skill.Name</span>
|
||||
</button>
|
||||
|
||||
@@ -47,7 +47,7 @@ public partial class SkillGroupBlock
|
||||
public EventCallback<CharacterSheetSkill> EditSkillRequested { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> RollSkillRequested { get; set; }
|
||||
public EventCallback<CharacterSheetSkill> RollSkillRequested { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> DeleteSkillRequested { get; set; }
|
||||
|
||||
@@ -217,3 +217,15 @@
|
||||
AvailableUsernames="State.KnownUsernames"
|
||||
CharacterSaved="Campaigns.OnCharacterUpdatedAsync"
|
||||
CancelRequested="Campaigns.CloseCharacterModals"/>
|
||||
|
||||
<RolemasterSkillRollModal
|
||||
Visible="State.ShowRolemasterSkillRollModal"
|
||||
SkillName="@(State.PendingRolemasterSkillRoll?.Name ?? string.Empty)"
|
||||
Expression="@(State.PendingRolemasterSkillRoll?.DiceRollDefinition ?? string.Empty)"
|
||||
ModifierText="@State.PendingRolemasterSituationalModifier"
|
||||
ModifierTextChanged="@(text => State.PendingRolemasterSituationalModifier = text)"
|
||||
ErrorMessage="@State.PendingRolemasterSkillRollError"
|
||||
IsMutating="State.IsMutating"
|
||||
IsSubmitting="State.IsSubmittingRolemasterSkillRoll"
|
||||
ConfirmRequested="Play.SubmitRolemasterSkillRollAsync"
|
||||
CancelRequested="Play.CancelRolemasterSkillRollAsync"/>
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using RpgRoller.Components.Pages.HomeControls;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
@@ -143,22 +144,70 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task RollSkillAsync(Guid skillId)
|
||||
public Task RollSkillAsync(CharacterSheetSkill skill)
|
||||
{
|
||||
if (state.SelectedCampaign is null)
|
||||
{
|
||||
feedback.SetStatus("No campaign selected.", true);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (string.Equals(state.SelectedCampaign.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
OpenRolemasterSkillRollModal(skill);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return ExecuteSkillRollAsync(skill.Id, 0);
|
||||
}
|
||||
|
||||
public async Task SubmitRolemasterSkillRollAsync(string situationalModifierText)
|
||||
{
|
||||
if (state.PendingRolemasterSkillRoll is null)
|
||||
return;
|
||||
|
||||
if (!TryParseSituationalModifier(situationalModifierText, out var situationalModifier, out var errorMessage))
|
||||
{
|
||||
state.PendingRolemasterSkillRollError = errorMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
state.PendingRolemasterSituationalModifier = situationalModifierText;
|
||||
state.PendingRolemasterSkillRollError = null;
|
||||
state.IsSubmittingRolemasterSkillRoll = true;
|
||||
try
|
||||
{
|
||||
await ExecuteSkillRollAsync(state.PendingRolemasterSkillRoll.Id, situationalModifier, keepModalOpenOnError: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.IsSubmittingRolemasterSkillRoll = false;
|
||||
}
|
||||
}
|
||||
|
||||
public Task CancelRolemasterSkillRollAsync()
|
||||
{
|
||||
if (state.IsSubmittingRolemasterSkillRoll)
|
||||
return Task.CompletedTask;
|
||||
|
||||
CloseRolemasterSkillRollModal();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task ExecuteSkillRollAsync(Guid skillId, int situationalModifier, bool keepModalOpenOnError = false)
|
||||
{
|
||||
state.IsMutating = true;
|
||||
try
|
||||
{
|
||||
var roll = await apiClient.RequestAsync<RollResult>("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(state.RollVisibility));
|
||||
var roll = await apiClient.RequestAsync<RollResult>("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(state.RollVisibility, situationalModifier));
|
||||
CloseRolemasterSkillRollModal();
|
||||
await HandleRecordedRollAsync(roll);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
if (keepModalOpenOnError)
|
||||
state.PendingRolemasterSkillRollError = ex.Message;
|
||||
else
|
||||
feedback.SetStatus(ex.Message, true);
|
||||
}
|
||||
finally
|
||||
@@ -217,6 +266,48 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
|
||||
state.CurrentCampaignState = null;
|
||||
}
|
||||
|
||||
private void OpenRolemasterSkillRollModal(CharacterSheetSkill skill)
|
||||
{
|
||||
state.PendingRolemasterSkillRoll = skill;
|
||||
state.PendingRolemasterSituationalModifier = string.Empty;
|
||||
state.PendingRolemasterSkillRollError = null;
|
||||
state.ShowRolemasterSkillRollModal = true;
|
||||
}
|
||||
|
||||
private void CloseRolemasterSkillRollModal()
|
||||
{
|
||||
state.ShowRolemasterSkillRollModal = false;
|
||||
state.PendingRolemasterSkillRoll = null;
|
||||
state.PendingRolemasterSituationalModifier = string.Empty;
|
||||
state.PendingRolemasterSkillRollError = null;
|
||||
state.IsSubmittingRolemasterSkillRoll = false;
|
||||
}
|
||||
|
||||
private static bool TryParseSituationalModifier(string? text, out int situationalModifier, out string? errorMessage)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
situationalModifier = 0;
|
||||
errorMessage = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!int.TryParse(text.Trim(), out situationalModifier))
|
||||
{
|
||||
errorMessage = "Enter a whole number like 20, -15, or leave blank for 0.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (situationalModifier is < -MaxSituationalModifier or > MaxSituationalModifier)
|
||||
{
|
||||
errorMessage = $"Enter a whole number between {-MaxSituationalModifier} and {MaxSituationalModifier}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
errorMessage = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task EnsureSelectedCharacterActiveCoreAsync()
|
||||
{
|
||||
if (!state.SelectedCharacterId.HasValue || state.SelectedCampaign is null)
|
||||
@@ -312,4 +403,5 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
|
||||
}
|
||||
|
||||
private const int CampaignLogWindowSize = 25;
|
||||
private const int MaxSituationalModifier = 1000;
|
||||
}
|
||||
@@ -27,7 +27,7 @@ public sealed class WorkspaceState
|
||||
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 RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange, skill.RolemasterAutoRetry);
|
||||
|
||||
return skill.DiceRollDefinition;
|
||||
}
|
||||
@@ -67,8 +67,13 @@ public sealed class WorkspaceState
|
||||
|
||||
public bool ShowCreateCharacterModal { get; set; }
|
||||
public bool ShowEditCharacterModal { get; set; }
|
||||
public bool ShowRolemasterSkillRollModal { get; set; }
|
||||
public bool CanEditCharacterOwner { get; set; }
|
||||
public Guid? EditingCharacterId { get; set; }
|
||||
public CharacterSheetSkill? PendingRolemasterSkillRoll { get; set; }
|
||||
public string PendingRolemasterSituationalModifier { get; set; } = string.Empty;
|
||||
public string? PendingRolemasterSkillRollError { get; set; }
|
||||
public bool IsSubmittingRolemasterSkillRoll { get; set; }
|
||||
public CharacterFormModel CreateCharacterInitialModel { get; set; } = new();
|
||||
public CharacterFormModel EditCharacterInitialModel { get; set; } = new();
|
||||
public int CreateCharacterFormVersion { get; set; }
|
||||
|
||||
@@ -36,9 +36,9 @@ 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, int? FumbleRange = null);
|
||||
public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null, int? FumbleRange = null, bool RolemasterAutoRetry = false);
|
||||
|
||||
public sealed record UpdateSkillRequest(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, int? FumbleRange = null, bool RolemasterAutoRetry = false);
|
||||
|
||||
public sealed record SkillGroupSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange);
|
||||
|
||||
@@ -46,9 +46,9 @@ public sealed record CreateSkillGroupRequest(string Name, string DiceRollDefinit
|
||||
|
||||
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, int? FumbleRange);
|
||||
public sealed record SkillSummary(Guid Id, Guid CharacterId, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry);
|
||||
|
||||
public sealed record RollSkillRequest(string Visibility);
|
||||
public sealed record RollSkillRequest(string Visibility, int SituationalModifier = 0);
|
||||
|
||||
public sealed record CustomRollRequest(string Expression, string Visibility);
|
||||
|
||||
@@ -66,7 +66,7 @@ public sealed record RollDieResult
|
||||
{
|
||||
}
|
||||
|
||||
public RollDieResult(int roll, bool crit, bool fumble, bool wild, bool removed, bool added, int? sequence = null, string? kind = null, int? signedContribution = null)
|
||||
public RollDieResult(int roll, bool crit, bool fumble, bool wild, bool removed, bool added, int? sequence = null, string? kind = null, int? signedContribution = null, int? attempt = null)
|
||||
{
|
||||
Roll = roll;
|
||||
Crit = crit;
|
||||
@@ -77,6 +77,7 @@ public sealed record RollDieResult
|
||||
Sequence = sequence;
|
||||
Kind = kind;
|
||||
SignedContribution = signedContribution;
|
||||
Attempt = attempt;
|
||||
}
|
||||
|
||||
public int Roll { get; init; }
|
||||
@@ -94,13 +95,16 @@ public sealed record RollDieResult
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? SignedContribution { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? Attempt { 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, int? FumbleRange);
|
||||
|
||||
public sealed record CharacterSheetSkill(Guid Id, Guid? SkillGroupId, 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, int? FumbleRange, bool RolemasterAutoRetry);
|
||||
|
||||
public sealed record CharacterSheet(Guid CharacterId, CharacterSheetSkillGroup[] SkillGroups, CharacterSheetSkill[] Skills);
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ public sealed class RpgRollerDbContext(DbContextOptions<RpgRollerDbContext> opti
|
||||
entity.Property(x => x.WildDice).IsRequired();
|
||||
entity.Property(x => x.AllowFumble).IsRequired();
|
||||
entity.Property(x => x.FumbleRange).IsRequired(false);
|
||||
entity.Property(x => x.RolemasterAutoRetry).IsRequired().HasDefaultValue(false);
|
||||
entity.HasIndex(x => x.CharacterId);
|
||||
entity.HasIndex(x => x.SkillGroupId);
|
||||
});
|
||||
|
||||
@@ -74,6 +74,7 @@ public sealed class Skill
|
||||
public required int WildDice { get; set; }
|
||||
public required bool AllowFumble { get; set; }
|
||||
public int? FumbleRange { get; set; }
|
||||
public bool RolemasterAutoRetry { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RollLogEntry
|
||||
|
||||
269
RpgRoller/Migrations/20260414204309_AddRolemasterAutoRetry.Designer.cs
generated
Normal file
269
RpgRoller/Migrations/20260414204309_AddRolemasterAutoRetry.Designer.cs
generated
Normal file
@@ -0,0 +1,269 @@
|
||||
// <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("20260414204309_AddRolemasterAutoRetry")]
|
||||
partial class AddRolemasterAutoRetry
|
||||
{
|
||||
/// <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<bool>("RolemasterAutoRetry")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
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,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace RpgRoller.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRolemasterAutoRetry : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "RolemasterAutoRetry",
|
||||
table: "Skills",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RolemasterAutoRetry",
|
||||
table: "Skills");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,11 @@ namespace RpgRoller.Migrations
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("RolemasterAutoRetry")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<Guid?>("SkillGroupId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
@@ -5,18 +5,18 @@ namespace RpgRoller.Services;
|
||||
|
||||
public static class CampaignLogSummaryBuilder
|
||||
{
|
||||
public static string BuildCompactLogSummary(IReadOnlyList<RollDieResult> dice)
|
||||
public static string BuildCompactLogSummary(IReadOnlyList<RollDieResult> dice, string? breakdown = null)
|
||||
{
|
||||
if (dice.Count == 0)
|
||||
return "No detail available.";
|
||||
|
||||
if (dice.Any(die => IsRolemasterDieKind(die.Kind)))
|
||||
return BuildRolemasterCompactLogSummary(dice);
|
||||
return BuildRolemasterCompactLogSummary(dice, breakdown);
|
||||
|
||||
return string.Join(", ", dice.Select(die => die.Roll.ToString()));
|
||||
}
|
||||
|
||||
public static string[]? BuildCompactLogEventBadges(RulesetKind ruleset, string? expression, IReadOnlyList<RollDieResult> dice)
|
||||
public static string[]? BuildCompactLogEventBadges(RulesetKind ruleset, string? expression, IReadOnlyList<RollDieResult> dice, string? breakdown = null)
|
||||
{
|
||||
var badges = new List<string>();
|
||||
|
||||
@@ -38,6 +38,7 @@ public static class CampaignLogSummaryBuilder
|
||||
AddBadgeIfMissing(badges, dice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal) && !die.SignedContribution.HasValue), "rf");
|
||||
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 100), "r100");
|
||||
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 66), "r66");
|
||||
AddRetryBadgeIfPresent(badges, breakdown);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -53,29 +54,31 @@ public static class CampaignLogSummaryBuilder
|
||||
return breakdown[..separatorIndex];
|
||||
}
|
||||
|
||||
private static string BuildRolemasterCompactLogSummary(IReadOnlyList<RollDieResult> dice)
|
||||
private static string BuildRolemasterCompactLogSummary(IReadOnlyList<RollDieResult> dice, string? breakdown)
|
||||
{
|
||||
var openEndedInitial = dice.FirstOrDefault(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal));
|
||||
var retryBonus = RolemasterRetryPolicy.TryExtractRetryBonus(breakdown);
|
||||
var summaryDice = retryBonus.HasValue ? dice.Where(die => die.Attempt != 2).ToArray() : dice;
|
||||
var openEndedInitial = summaryDice.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();
|
||||
var highFollowUps = summaryDice.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";
|
||||
return AppendRetryNote($"{openEndedInitial.Roll} + {string.Join(" + ", highFollowUps)} | open-ended high", retryBonus);
|
||||
|
||||
var lowFollowUps = dice.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedLowSubtract, StringComparison.Ordinal)).Select(die => die.Roll.ToString()).ToArray();
|
||||
var lowFollowUps = summaryDice.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedLowSubtract, StringComparison.Ordinal)).Select(die => die.Roll.ToString()).ToArray();
|
||||
if (lowFollowUps.Length > 0)
|
||||
return $"({RollBreakdownFormatter.FormatRolemasterTriggerRoll(openEndedInitial.Roll)}) {string.Join(" ", lowFollowUps.Select(roll => $"-{roll}"))} | open-ended low";
|
||||
return AppendRetryNote($"({RollBreakdownFormatter.FormatRolemasterTriggerRoll(openEndedInitial.Roll)}) {string.Join(" ", lowFollowUps.Select(roll => $"-{roll}"))} | open-ended low", retryBonus);
|
||||
|
||||
return $"{openEndedInitial.Roll} | open-ended";
|
||||
return AppendRetryNote($"{openEndedInitial.Roll} | open-ended", retryBonus);
|
||||
}
|
||||
|
||||
if (dice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterStandard, StringComparison.Ordinal)))
|
||||
if (summaryDice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterStandard, StringComparison.Ordinal)))
|
||||
{
|
||||
var preview = string.Join(" + ", dice.Select(die => die.Roll.ToString()));
|
||||
return $"{preview} | rolemaster";
|
||||
var preview = string.Join(" + ", summaryDice.Select(die => die.Roll.ToString()));
|
||||
return AppendRetryNote($"{preview} | rolemaster", retryBonus);
|
||||
}
|
||||
|
||||
return string.Join(", ", dice.Select(die => die.Roll.ToString()));
|
||||
return AppendRetryNote(string.Join(", ", summaryDice.Select(die => die.Roll.ToString())), retryBonus);
|
||||
}
|
||||
|
||||
private static bool IsRolemasterDieKind(string? kind)
|
||||
@@ -91,6 +94,20 @@ public static class CampaignLogSummaryBuilder
|
||||
badges.Add(code);
|
||||
}
|
||||
|
||||
private static void AddRetryBadgeIfPresent(List<string> badges, string? breakdown)
|
||||
{
|
||||
var retryBonus = RolemasterRetryPolicy.TryExtractRetryBonus(breakdown);
|
||||
if (!retryBonus.HasValue)
|
||||
return;
|
||||
|
||||
AddBadgeIfMissing(badges, true, retryBonus.Value == 5 ? "rs5" : "rs10");
|
||||
}
|
||||
|
||||
private static string AppendRetryNote(string summary, int? retryBonus)
|
||||
{
|
||||
return retryBonus.HasValue ? $"{summary} | retry +{retryBonus.Value}" : summary;
|
||||
}
|
||||
|
||||
private static bool IsSingleD20Expression(string expression)
|
||||
{
|
||||
var parsedExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, expression);
|
||||
|
||||
@@ -55,7 +55,7 @@ public static class GameDtoMapper
|
||||
|
||||
public static SkillSummary ToSkillSummary(Skill skill)
|
||||
{
|
||||
return new(skill.Id, skill.CharacterId, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange);
|
||||
return new(skill.Id, skill.CharacterId, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange, skill.RolemasterAutoRetry);
|
||||
}
|
||||
|
||||
public static RollResult ToRollResult(RollLogEntry entry, IReadOnlyList<RollDieResult> dice)
|
||||
@@ -98,6 +98,6 @@ public static class GameDtoMapper
|
||||
|
||||
private static CharacterSheetSkill ToCharacterSheetSkill(Skill skill)
|
||||
{
|
||||
return new(skill.Id, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange);
|
||||
return new(skill.Id, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange, skill.RolemasterAutoRetry);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace RpgRoller.Services;
|
||||
|
||||
public sealed class GameRollService(GameStateStore stateStore, GamePersistenceService persistenceService, IDiceRoller diceRoller)
|
||||
{
|
||||
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility)
|
||||
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier = 0)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
@@ -28,11 +28,17 @@ public sealed class GameRollService(GameStateStore stateStore, GamePersistenceSe
|
||||
if (!parsedExpression.Succeeded)
|
||||
return ServiceResult<RollResult>.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message);
|
||||
|
||||
if (situationalModifier != 0 && campaign.Ruleset != RulesetKind.Rolemaster)
|
||||
return ServiceResult<RollResult>.Failure("invalid_situational_modifier", "Situational modifiers are supported only for Rolemaster skill rolls.");
|
||||
|
||||
if (campaign.Ruleset == RulesetKind.Rolemaster && (situationalModifier < -MaxSituationalModifier || situationalModifier > MaxSituationalModifier))
|
||||
return ServiceResult<RollResult>.Failure("invalid_situational_modifier", $"Situational modifier must be between {-MaxSituationalModifier} and {MaxSituationalModifier}.");
|
||||
|
||||
var parsedVisibility = RollVisibilityParser.Parse(visibility);
|
||||
if (!parsedVisibility.Succeeded)
|
||||
return ServiceResult<RollResult>.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message);
|
||||
|
||||
var roll = m_RollEngine.Roll(campaign.Ruleset, parsedExpression.Value!, skill.WildDice, skill.AllowFumble, skill.FumbleRange);
|
||||
var roll = m_RollEngine.Roll(campaign.Ruleset, parsedExpression.Value!, skill.WildDice, skill.AllowFumble, skill.FumbleRange, skill.RolemasterAutoRetry, situationalModifier);
|
||||
return RecordRollLocked(user, campaign, character, skill.Id, parsedVisibility.Value, roll, parsedExpression.Value!.Canonical);
|
||||
}
|
||||
}
|
||||
@@ -203,9 +209,9 @@ public sealed class GameRollService(GameStateStore stateStore, GamePersistenceSe
|
||||
var characterName = stateStore.CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character";
|
||||
var skillName = ResolveLoggedSkillName(entry);
|
||||
var loggedExpression = ResolveLoggedExpression(entry);
|
||||
var eventBadges = CampaignLogSummaryBuilder.BuildCompactLogEventBadges(campaign.Ruleset, loggedExpression, dice);
|
||||
var eventBadges = CampaignLogSummaryBuilder.BuildCompactLogEventBadges(campaign.Ruleset, loggedExpression, dice, entry.Breakdown);
|
||||
|
||||
return GameDtoMapper.ToCampaignLogListEntry(entry, characterName, skillName, ResolveLogRollerLabel(user, campaign, entry), ResolveLogVisibilityLabel(user, campaign, entry), ResolveLogVisibilityStyle(user, campaign, entry), CampaignLogSummaryBuilder.BuildCompactLogSummary(dice), eventBadges);
|
||||
return GameDtoMapper.ToCampaignLogListEntry(entry, characterName, skillName, ResolveLogRollerLabel(user, campaign, entry), ResolveLogVisibilityLabel(user, campaign, entry), ResolveLogVisibilityStyle(user, campaign, entry), CampaignLogSummaryBuilder.BuildCompactLogSummary(dice, entry.Breakdown), eventBadges);
|
||||
}
|
||||
|
||||
private static string SerializeDice(IReadOnlyList<RollDieResult> dice)
|
||||
@@ -287,6 +293,7 @@ public sealed class GameRollService(GameStateStore stateStore, GamePersistenceSe
|
||||
|
||||
private const int CampaignLogHistoryWindowSize = 100;
|
||||
private const int CampaignLogLivePageSize = 25;
|
||||
private const int MaxSituationalModifier = 1000;
|
||||
private const string CustomRollBreakdownSeparator = " => ";
|
||||
private const string CustomRollLabel = "Custom roll";
|
||||
private static readonly Guid CustomRollSkillId = Guid.Empty;
|
||||
|
||||
@@ -140,14 +140,14 @@ public sealed class GameService : IGameService
|
||||
return m_SkillService.DeleteSkillGroup(sessionToken, skillGroupId);
|
||||
}
|
||||
|
||||
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null)
|
||||
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false)
|
||||
{
|
||||
return m_SkillService.CreateSkill(sessionToken, characterId, name, diceRollDefinition, wildDice, allowFumble, skillGroupId, fumbleRange);
|
||||
return m_SkillService.CreateSkill(sessionToken, characterId, name, diceRollDefinition, wildDice, allowFumble, skillGroupId, fumbleRange, rolemasterAutoRetry);
|
||||
}
|
||||
|
||||
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null)
|
||||
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false)
|
||||
{
|
||||
return m_SkillService.UpdateSkill(sessionToken, skillId, name, diceRollDefinition, wildDice, allowFumble, skillGroupId, fumbleRange);
|
||||
return m_SkillService.UpdateSkill(sessionToken, skillId, name, diceRollDefinition, wildDice, allowFumble, skillGroupId, fumbleRange, rolemasterAutoRetry);
|
||||
}
|
||||
|
||||
public ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId)
|
||||
@@ -160,9 +160,9 @@ public sealed class GameService : IGameService
|
||||
return m_SkillService.GetCharacterSheet(sessionToken, characterId);
|
||||
}
|
||||
|
||||
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility)
|
||||
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier = 0)
|
||||
{
|
||||
return m_RollService.RollSkill(sessionToken, skillId, visibility);
|
||||
return m_RollService.RollSkill(sessionToken, skillId, visibility, situationalModifier);
|
||||
}
|
||||
|
||||
public ServiceResult<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility)
|
||||
|
||||
@@ -114,7 +114,7 @@ public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceS
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null)
|
||||
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
|
||||
@@ -134,7 +134,7 @@ public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceS
|
||||
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
|
||||
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
|
||||
|
||||
var skillValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange);
|
||||
var skillValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange, rolemasterAutoRetry);
|
||||
if (!skillValidation.Succeeded)
|
||||
return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message);
|
||||
|
||||
@@ -151,7 +151,8 @@ public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceS
|
||||
DiceRollDefinition = skillValidation.Value.CanonicalExpression,
|
||||
WildDice = skillValidation.Value.WildDice,
|
||||
AllowFumble = skillValidation.Value.AllowFumble,
|
||||
FumbleRange = skillValidation.Value.FumbleRange
|
||||
FumbleRange = skillValidation.Value.FumbleRange,
|
||||
RolemasterAutoRetry = skillValidation.Value.RolemasterAutoRetry
|
||||
};
|
||||
|
||||
stateStore.SkillsById[skill.Id] = skill;
|
||||
@@ -162,7 +163,7 @@ public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceS
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null)
|
||||
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
|
||||
@@ -183,7 +184,7 @@ public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceS
|
||||
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
|
||||
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
|
||||
|
||||
var skillValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange);
|
||||
var skillValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange, rolemasterAutoRetry);
|
||||
if (!skillValidation.Succeeded)
|
||||
return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message);
|
||||
|
||||
@@ -196,6 +197,7 @@ public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceS
|
||||
skill.WildDice = skillValidation.Value.WildDice;
|
||||
skill.AllowFumble = skillValidation.Value.AllowFumble;
|
||||
skill.FumbleRange = skillValidation.Value.FumbleRange;
|
||||
skill.RolemasterAutoRetry = skillValidation.Value.RolemasterAutoRetry;
|
||||
skill.SkillGroupId = resolvedSkillGroupId.Value;
|
||||
stateStore.TouchCharacterLocked(campaign.Id, character.Id);
|
||||
|
||||
|
||||
@@ -62,7 +62,8 @@ public static class GameStateCloneFactory
|
||||
DiceRollDefinition = skill.DiceRollDefinition,
|
||||
WildDice = skill.WildDice,
|
||||
AllowFumble = skill.AllowFumble,
|
||||
FumbleRange = skill.FumbleRange
|
||||
FumbleRange = skill.FumbleRange,
|
||||
RolemasterAutoRetry = skill.RolemasterAutoRetry
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -31,12 +31,12 @@ public interface IGameService
|
||||
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, 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<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false);
|
||||
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false);
|
||||
ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId);
|
||||
ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId);
|
||||
|
||||
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility);
|
||||
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier = 0);
|
||||
ServiceResult<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility);
|
||||
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);
|
||||
ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null);
|
||||
|
||||
32
RpgRoller/Services/RolemasterRetryPolicy.cs
Normal file
32
RpgRoller/Services/RolemasterRetryPolicy.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
namespace RpgRoller.Services;
|
||||
|
||||
public static class RolemasterRetryPolicy
|
||||
{
|
||||
public static int? ResolveAutoRetryBonus(int firstResult)
|
||||
{
|
||||
if (firstResult is >= 76 and <= 90)
|
||||
return 5;
|
||||
|
||||
if (firstResult is >= 91 and <= 110)
|
||||
return 10;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static int? TryExtractRetryBonus(string? breakdown)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(breakdown))
|
||||
return null;
|
||||
|
||||
if (breakdown.Contains(RetryPlusFiveMarker, StringComparison.Ordinal))
|
||||
return 5;
|
||||
|
||||
if (breakdown.Contains(RetryPlusTenMarker, StringComparison.Ordinal))
|
||||
return 10;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public const string RetryPlusFiveMarker = "; retry(+5):";
|
||||
public const string RetryPlusTenMarker = "; retry(+10):";
|
||||
}
|
||||
@@ -5,58 +5,73 @@ namespace RpgRoller.Services;
|
||||
|
||||
public sealed class RolemasterRollEngine(IDiceRoller diceRoller)
|
||||
{
|
||||
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(DiceExpression expression, int? fumbleRange)
|
||||
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(DiceExpression expression, int? fumbleRange, bool rolemasterAutoRetry, int situationalModifier = 0)
|
||||
{
|
||||
return expression.Kind switch
|
||||
{
|
||||
DiceExpressionKind.RolemasterOpenEndedPercentile => RollOpenEnded(expression, fumbleRange.GetValueOrDefault()),
|
||||
_ => RollStandard(expression)
|
||||
DiceExpressionKind.RolemasterOpenEndedPercentile => RollOpenEnded(expression, fumbleRange.GetValueOrDefault(), rolemasterAutoRetry, situationalModifier),
|
||||
_ => RollStandard(expression, situationalModifier)
|
||||
};
|
||||
}
|
||||
|
||||
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollStandard(DiceExpression expression)
|
||||
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollStandard(DiceExpression expression, int situationalModifier, int? attempt = null)
|
||||
{
|
||||
var diceValues = new int[expression.DiceCount];
|
||||
var dice = new RollDieResult[expression.DiceCount];
|
||||
var total = expression.Modifier;
|
||||
var total = expression.Modifier + situationalModifier;
|
||||
for (var i = 0; i < expression.DiceCount; i += 1)
|
||||
{
|
||||
var value = diceRoller.Roll(expression.Sides);
|
||||
diceValues[i] = value;
|
||||
dice[i] = CreateRolemasterDie(value, i + 1, RollDieKinds.RolemasterStandard, value);
|
||||
dice[i] = CreateRolemasterDie(value, i + 1, RollDieKinds.RolemasterStandard, value, attempt);
|
||||
total += value;
|
||||
}
|
||||
|
||||
return (total, RollBreakdownFormatter.BuildBreakdown(diceValues, expression.Modifier, total), dice);
|
||||
return (total, RollBreakdownFormatter.BuildRolemasterModifierBreakdown(diceValues, expression.Modifier, situationalModifier, total), dice);
|
||||
}
|
||||
|
||||
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollOpenEnded(DiceExpression expression, int fumbleRange)
|
||||
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollOpenEnded(DiceExpression expression, int fumbleRange, bool rolemasterAutoRetry, int situationalModifier)
|
||||
{
|
||||
var firstAttempt = RollOpenEndedAttempt(expression, fumbleRange, situationalModifier);
|
||||
var retryBonus = rolemasterAutoRetry ? RolemasterRetryPolicy.ResolveAutoRetryBonus(firstAttempt.Total) : null;
|
||||
if (!retryBonus.HasValue)
|
||||
return firstAttempt;
|
||||
|
||||
var retryAttempt = RollOpenEndedAttempt(expression, fumbleRange, situationalModifier, 2);
|
||||
var finalTotal = retryAttempt.Total + retryBonus.Value;
|
||||
var breakdown = RollBreakdownFormatter.BuildRolemasterRetryBreakdown(firstAttempt.Breakdown, retryBonus.Value, retryAttempt.Breakdown, finalTotal);
|
||||
var dice = AddAttemptMarker(firstAttempt.Dice, 1).Concat(retryAttempt.Dice).ToArray();
|
||||
|
||||
return (finalTotal, breakdown, dice);
|
||||
}
|
||||
|
||||
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollOpenEndedAttempt(DiceExpression expression, int fumbleRange, int situationalModifier, int? attempt = null)
|
||||
{
|
||||
var initialRoll = 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 dice = new List<RollDieResult> { CreateRolemasterDie(initialRoll, 1, RollDieKinds.RolemasterOpenEndedInitial, initialContribution, attempt) };
|
||||
|
||||
var baseTotal = initialRoll <= fumbleRange ? 0 : initialRoll;
|
||||
var subtractFollowUps = false;
|
||||
if (initialRoll >= 96)
|
||||
{
|
||||
followUpRolls.AddRange(RollHighOpenEndedChain(dice, 2, false));
|
||||
followUpRolls.AddRange(RollHighOpenEndedChain(dice, 2, false, attempt));
|
||||
baseTotal += followUpRolls.Sum();
|
||||
}
|
||||
else if (initialRoll <= fumbleRange)
|
||||
{
|
||||
subtractFollowUps = true;
|
||||
followUpRolls.AddRange(RollHighOpenEndedChain(dice, 2, true));
|
||||
followUpRolls.AddRange(RollHighOpenEndedChain(dice, 2, true, attempt));
|
||||
baseTotal -= followUpRolls.Sum();
|
||||
}
|
||||
|
||||
var total = baseTotal + expression.Modifier;
|
||||
var breakdown = RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(initialRoll, followUpRolls, subtractFollowUps, expression.Modifier, total);
|
||||
var total = baseTotal + expression.Modifier + situationalModifier;
|
||||
var breakdown = RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(initialRoll, followUpRolls, subtractFollowUps, expression.Modifier, total, situationalModifier);
|
||||
return (total, breakdown, dice);
|
||||
}
|
||||
|
||||
private IEnumerable<int> RollHighOpenEndedChain(List<RollDieResult> dice, int sequenceStart, bool subtract)
|
||||
private IEnumerable<int> RollHighOpenEndedChain(List<RollDieResult> dice, int sequenceStart, bool subtract, int? attempt)
|
||||
{
|
||||
var followUpRolls = new List<int>();
|
||||
var sequence = sequenceStart;
|
||||
@@ -65,7 +80,7 @@ public sealed class RolemasterRollEngine(IDiceRoller diceRoller)
|
||||
{
|
||||
var roll = diceRoller.Roll(100);
|
||||
followUpRolls.Add(roll);
|
||||
dice.Add(CreateRolemasterDie(roll, sequence, subtract ? RollDieKinds.RolemasterOpenEndedLowSubtract : RollDieKinds.RolemasterOpenEndedHigh, subtract ? -roll : roll));
|
||||
dice.Add(CreateRolemasterDie(roll, sequence, subtract ? RollDieKinds.RolemasterOpenEndedLowSubtract : RollDieKinds.RolemasterOpenEndedHigh, subtract ? -roll : roll, attempt));
|
||||
|
||||
sequence += 1;
|
||||
if (roll < 96)
|
||||
@@ -75,8 +90,13 @@ public sealed class RolemasterRollEngine(IDiceRoller diceRoller)
|
||||
return followUpRolls;
|
||||
}
|
||||
|
||||
private static RollDieResult CreateRolemasterDie(int roll, int sequence, string kind, int? signedContribution)
|
||||
private static IReadOnlyList<RollDieResult> AddAttemptMarker(IReadOnlyList<RollDieResult> dice, int attempt)
|
||||
{
|
||||
return new(roll, false, false, false, false, kind == RollDieKinds.RolemasterOpenEndedHigh, sequence, kind, signedContribution);
|
||||
return dice.Select(die => die with { Attempt = attempt }).ToArray();
|
||||
}
|
||||
|
||||
private static RollDieResult CreateRolemasterDie(int roll, int sequence, string kind, int? signedContribution, int? attempt)
|
||||
{
|
||||
return new(roll, false, false, false, false, kind == RollDieKinds.RolemasterOpenEndedHigh, sequence, kind, signedContribution, attempt);
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,17 @@ public static class RollBreakdownFormatter
|
||||
}
|
||||
|
||||
public static string BuildRolemasterOpenEndedBreakdown(int initialRoll, IReadOnlyList<int> followUpRolls, bool subtractFollowUps, int modifier, int total)
|
||||
{
|
||||
return BuildRolemasterOpenEndedBreakdown(initialRoll, followUpRolls, subtractFollowUps, modifier, total, 0);
|
||||
}
|
||||
|
||||
public static string BuildRolemasterOpenEndedBreakdown(int initialRoll, IReadOnlyList<int> followUpRolls, bool subtractFollowUps, int modifier, int total, int situationalModifier)
|
||||
{
|
||||
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());
|
||||
AddRolemasterModifierSegments(segments, modifier, situationalModifier);
|
||||
|
||||
return $"{string.Join(" ", segments)} = {total}";
|
||||
}
|
||||
@@ -32,7 +34,12 @@ public static class RollBreakdownFormatter
|
||||
core = $"{core}+{followUpBreakdown}";
|
||||
}
|
||||
|
||||
return BuildModifierBreakdown(core, modifier, total);
|
||||
return BuildRolemasterModifierBreakdown(core, modifier, situationalModifier, total);
|
||||
}
|
||||
|
||||
public static string BuildRolemasterRetryBreakdown(string firstAttemptBreakdown, int retryBonus, string retryAttemptBreakdown, int finalTotal)
|
||||
{
|
||||
return $"{firstAttemptBreakdown}; retry(+{retryBonus}): {retryAttemptBreakdown}; final={finalTotal}";
|
||||
}
|
||||
|
||||
public static string FormatRolemasterTriggerRoll(int roll)
|
||||
@@ -49,4 +56,40 @@ public static class RollBreakdownFormatter
|
||||
_ => $"{core}={total}"
|
||||
};
|
||||
}
|
||||
|
||||
public static string BuildRolemasterModifierBreakdown(IEnumerable<int> diceValues, int modifier, int situationalModifier, int total)
|
||||
{
|
||||
var dicePart = string.Join("+", diceValues);
|
||||
if (string.IsNullOrWhiteSpace(dicePart))
|
||||
dicePart = "0";
|
||||
|
||||
return BuildRolemasterModifierBreakdown(dicePart, modifier, situationalModifier, total);
|
||||
}
|
||||
|
||||
public static string BuildRolemasterModifierBreakdown(string core, int modifier, int situationalModifier, int total)
|
||||
{
|
||||
if (situationalModifier == 0)
|
||||
return BuildModifierBreakdown(core, modifier, total);
|
||||
|
||||
return $"{core}{FormatSignedModifier(modifier)}{FormatSignedModifier(situationalModifier)}={total}";
|
||||
}
|
||||
|
||||
private static void AddRolemasterModifierSegments(List<string> segments, int modifier, int situationalModifier)
|
||||
{
|
||||
if (modifier != 0)
|
||||
segments.Add(FormatSignedModifier(modifier));
|
||||
|
||||
if (situationalModifier != 0)
|
||||
segments.Add(FormatSignedModifier(situationalModifier));
|
||||
}
|
||||
|
||||
private static string FormatSignedModifier(int modifier)
|
||||
{
|
||||
return modifier switch
|
||||
{
|
||||
> 0 => $"+{modifier}",
|
||||
< 0 => modifier.ToString(),
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,13 @@ namespace RpgRoller.Services;
|
||||
|
||||
public sealed class RollEngine(StandardRollEngine standardRollEngine, D6RollEngine d6RollEngine, RolemasterRollEngine rolemasterRollEngine)
|
||||
{
|
||||
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange)
|
||||
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange, bool rolemasterAutoRetry = false, int situationalModifier = 0)
|
||||
{
|
||||
if (ruleset == RulesetKind.D6)
|
||||
return d6RollEngine.Roll(expression, wildDice, allowFumble);
|
||||
|
||||
if (ruleset == RulesetKind.Rolemaster)
|
||||
return rolemasterRollEngine.Roll(expression, fumbleRange);
|
||||
return rolemasterRollEngine.Roll(expression, fumbleRange, rolemasterAutoRetry, situationalModifier);
|
||||
|
||||
return standardRollEngine.Roll(expression);
|
||||
}
|
||||
|
||||
@@ -4,63 +4,72 @@ namespace RpgRoller.Services;
|
||||
|
||||
public static class SkillDefinitionValidator
|
||||
{
|
||||
public static ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)> Validate(RulesetKind ruleset, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange)
|
||||
public static ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)> Validate(RulesetKind ruleset, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange, bool rolemasterAutoRetry = false)
|
||||
{
|
||||
var expressionValidation = DiceRules.ParseExpression(ruleset, diceRollDefinition);
|
||||
if (!expressionValidation.Succeeded)
|
||||
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
|
||||
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
|
||||
|
||||
var optionsValidation = ValidateOptions(ruleset, expressionValidation.Value!, wildDice, allowFumble, fumbleRange);
|
||||
var optionsValidation = ValidateOptions(ruleset, expressionValidation.Value!, wildDice, allowFumble, fumbleRange, rolemasterAutoRetry);
|
||||
if (!optionsValidation.Succeeded)
|
||||
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, int? FumbleRange, bool RolemasterAutoRetry)>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
|
||||
|
||||
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Success((expressionValidation.Value!.Canonical, optionsValidation.Value.WildDice, optionsValidation.Value.AllowFumble, optionsValidation.Value.FumbleRange));
|
||||
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Success((expressionValidation.Value!.Canonical, optionsValidation.Value.WildDice, optionsValidation.Value.AllowFumble, optionsValidation.Value.FumbleRange, optionsValidation.Value.RolemasterAutoRetry));
|
||||
}
|
||||
|
||||
private static ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)> ValidateOptions(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange)
|
||||
private static ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)> ValidateOptions(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange, bool rolemasterAutoRetry)
|
||||
{
|
||||
if (wildDice < 0 || wildDice > 50)
|
||||
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_wild_dice", "Wild dice must be between 0 and 50.");
|
||||
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.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, int? FumbleRange)>.Failure("invalid_wild_dice", "D6 skills require at least one wild die.");
|
||||
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_wild_dice", "D6 skills require at least one wild die.");
|
||||
|
||||
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, bool RolemasterAutoRetry)>.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));
|
||||
if (rolemasterAutoRetry)
|
||||
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_rolemaster_retry", "Automatic retry is only supported for Rolemaster open-ended percentile skills.");
|
||||
|
||||
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Success((wildDice, allowFumble, null, 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.");
|
||||
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.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.");
|
||||
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.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.");
|
||||
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.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, bool RolemasterAutoRetry)>.Failure("invalid_fumble_range", "Fumble range must be between 0 and 95.");
|
||||
|
||||
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Success((0, false, fumbleRange));
|
||||
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Success((0, false, fumbleRange, rolemasterAutoRetry));
|
||||
}
|
||||
|
||||
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, bool RolemasterAutoRetry)>.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 (rolemasterAutoRetry)
|
||||
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_rolemaster_retry", "Automatic retry is only supported for Rolemaster open-ended percentile skills.");
|
||||
|
||||
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Success((0, false, null, false));
|
||||
}
|
||||
|
||||
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, bool RolemasterAutoRetry)>.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));
|
||||
if (rolemasterAutoRetry)
|
||||
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_rolemaster_retry", "Automatic retry is only supported for Rolemaster open-ended percentile skills.");
|
||||
|
||||
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Success((0, false, null, false));
|
||||
}
|
||||
}
|
||||
@@ -1064,6 +1064,10 @@ select:focus-visible {
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.rolemaster-roll-modal {
|
||||
width: min(28rem, 100%);
|
||||
}
|
||||
|
||||
.mobile-bottom-nav {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
|
||||
285
TASKS.md
285
TASKS.md
@@ -1,241 +1,198 @@
|
||||
# Rolemaster Automatic Skipp Retry
|
||||
# Rolemaster skill roll situational modifier modal
|
||||
|
||||
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
|
||||
|
||||
`PLANS.md` is checked into the repo at `PLANS.md`. This document must be maintained in accordance with that file.
|
||||
`PLANS.md` is checked into the repository root. This document must be maintained in accordance with `PLANS.md`.
|
||||
|
||||
## Purpose / Big Picture
|
||||
|
||||
After this change, a Rolemaster skill can opt into an automatic retry when its first result lands in specific “skipp” bands. The player will be able to toggle that behavior while creating or editing a Rolemaster open-ended skill, roll the skill, and then see the retry clearly in the campaign log card through a special badge and readable summary text. The detailed roll view will still show enough information to explain why the retry happened and what final result was recorded.
|
||||
After this change, clicking the dice button for any Rolemaster skill on the play screen will no longer roll immediately. Instead, a modal dialog will open first and ask for a one-time situational modifier for that upcoming roll. The player can leave it blank for zero, enter a positive number such as `20` for a bonus, or enter a negative number such as `-15` for a penalty. Pressing Enter will confirm the roll, pressing Escape will cancel it, and clicking outside the modal will also cancel it.
|
||||
|
||||
For this feature, a “skipp” means a Rolemaster open-ended percentile skill roll whose first fully evaluated result, including the skill expression modifier and any low-end subtraction chain, lands in one of the retry windows before any retry bonus is applied. This plan preserves the user-provided thresholds exactly: results `77` through `90` grant a retry with `+5`; results `91` through `110` grant a retry with `+10`.
|
||||
The important user-visible rule is that this temporary modifier must be applied everywhere the skill roll logic already uses the skill’s built-in modifier. For a skill stored as `d100!+50`, entering `20` means the first Rolemaster attempt is evaluated as `roll + 50 + 20`, not as a post-processing adjustment. That means an initial result of `8` becomes `8+50+20=78`, which falls into the existing automatic retry band and therefore triggers the retry flow. The retry attempt must also include the same `+20` situational modifier.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] (2026-04-04 23:52Z) Reviewed `PLANS.md` and the current Rolemaster roll, skill-form, API, and log-card code paths.
|
||||
- [x] (2026-04-04 23:52Z) Authored this ExecPlan in `TASKS.md`.
|
||||
- [ ] Add a persisted per-skill toggle for Rolemaster automatic skipp retry and thread it through API contracts, DTOs, in-memory state, and EF Core migration paths.
|
||||
- [ ] Implement retry-aware Rolemaster roll execution, readable breakdown formatting, and compact log badge/summary output.
|
||||
- [ ] Update Blazor skill create/edit flows so the toggle is shown only when it is valid and stale values are cleared when it is not.
|
||||
- [ ] Add or update unit, API, persistence, payload-budget, and browser tests that prove the feature end to end.
|
||||
- [ ] Update `README.md`, run `pwsh ./scripts/ci-local.ps1`, and commit the finished implementation.
|
||||
- [x] (2026-04-14 21:27:50Z) Created the initial ExecPlan in `TASKS.md`, grounded in the current workspace play flow, API contract, and Rolemaster retry implementation.
|
||||
- [x] (2026-04-14 21:39:42Z) Added transient `SituationalModifier` support to the skill-roll request, API endpoint, service facade, and roll pipeline without adding persistence or schema changes.
|
||||
- [x] (2026-04-14 21:51:33Z) Added a Rolemaster-only pre-roll modal on the play screen with autofocus, Escape dismissal, Enter submit, outside-click dismissal, and inline validation for signed integer input.
|
||||
- [x] (2026-04-14 21:39:42Z) Updated Rolemaster roll execution and breakdown formatting so temporary modifiers are shown explicitly and feed retry-band evaluation plus retry attempts.
|
||||
- [x] (2026-04-14 21:51:33Z) Added service, API, and Playwright coverage for the new behavior, updated `README.md`, and prepared the touched files for cleanup, full CI, and commit.
|
||||
|
||||
## Surprises & Discoveries
|
||||
|
||||
- Observation: The current Rolemaster engine has only two special open-ended branches: high open-ended chaining and low-end subtraction. There is no second-attempt concept today.
|
||||
Evidence: `RpgRoller/Services/RolemasterRollEngine.cs` calls `RollHighOpenEndedChain` for high rolls and for low-end subtraction, then immediately formats the final total.
|
||||
- Observation: `TASKS.md` was empty before this plan was written, so this ExecPlan now defines the full intended work from scratch.
|
||||
Evidence: `Get-Item D:\Code\RpgRoller\TASKS.md | Format-List Length` reported `Length : 0`.
|
||||
|
||||
- Observation: Compact log badges are not stored anywhere. They are recalculated when a log page is read, so any retry signal must be derivable from persisted roll data.
|
||||
Evidence: `RpgRoller/Services/GameRollService.cs` calls `CampaignLogSummaryBuilder.BuildCompactLogEventBadges(...)` while building `CampaignLogListEntry`; `RollLogEntry` does not persist badges.
|
||||
- Observation: the situational modifier fit cleanly as transient request data. No `Skill`, `RollLogEntry`, migration, or EF model change was needed for the first implementation slice.
|
||||
Evidence: the change only touched `RollSkillRequest`, the roll endpoint/service path, and Rolemaster roll formatting/execution files.
|
||||
|
||||
- Observation: The Rolemaster-specific skill UI already hides and normalizes invalid options when the expression is not open-ended percentile, which gives this feature a natural home.
|
||||
Evidence: `RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs` and `CharacterPanel.razor.cs` already clear `FumbleRange` when the selected Rolemaster expression is not open-ended.
|
||||
- Observation: the current Rolemaster retry rule is already based on the fully computed first attempt total, not just the raw die result, which matches the new requirement once the temporary modifier is included in that total.
|
||||
Evidence: `RpgRoller/Services/RolemasterRollEngine.cs` resolves retry bands from `firstAttempt.Total`.
|
||||
|
||||
- Observation: the repository already uses Blazor modal patterns with overlays and `ElementReference.FocusAsync()` for autofocus, so the new modal can follow an existing local pattern instead of inventing a second approach.
|
||||
Evidence: `RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor(.cs)` renders a modal and focuses the name input in `OnAfterRenderAsync`.
|
||||
|
||||
- Observation: compact Rolemaster retry summaries still preview the trigger die, not the fully modified first-attempt arithmetic. The authoritative arithmetic belongs in the breakdown string.
|
||||
Evidence: with a situational modifier, the new service test now expects `8 | open-ended | retry +5` in the compact summary while the detailed breakdown is `8+50+20=78; retry(+5): 42+50+20=112; final=117`.
|
||||
|
||||
- Observation: Razor string parameters in the new modal call site need explicit `@` binding or the UI renders the property name as literal text.
|
||||
Evidence: the first Playwright failure snapshot showed the dialog rendering `State.PendingRolemasterSkillRollError` instead of the actual inline validation message until the binding was corrected in `Workspace.razor`.
|
||||
|
||||
## Decision Log
|
||||
|
||||
- Decision: The retry toggle is per skill, not per skill group, and custom rolls do not participate.
|
||||
Rationale: The request explicitly asks for “a toggle for a rolemaster skill.” A skill-group default would widen scope into template inheritance and create unclear behavior for ad hoc custom rolls.
|
||||
Date/Author: 2026-04-04 / Codex
|
||||
- Decision: the situational modifier will be transient request data only and will not be stored on `Skill`, `RollLogEntry`, or in a migration.
|
||||
Rationale: the feature is explicitly “once for the upcoming roll.” Persisting it would create stale state, require schema work, and misrepresent the feature.
|
||||
Date/Author: 2026-04-14 / Codex
|
||||
|
||||
- Decision: The retry windows are interpreted literally from the request: `77-90 => +5`, `91-110 => +10`. A result of `111` counts as success and doesn't need to be retried.
|
||||
Rationale: The user gave concrete inclusive and exclusive bounds. Preserving those exact bounds avoids silently changing game rules inside the plan.
|
||||
Date/Author: 2026-04-04 / Codex
|
||||
- Decision: `RollSkillRequest` will gain an integer `SituationalModifier` field with a default of `0`, and server-side skill-roll methods will accept the same value.
|
||||
Rationale: zero is the normal case, avoids null semantics through the stack, and keeps the request payload simple for both tests and UI code.
|
||||
Date/Author: 2026-04-14 / Codex
|
||||
|
||||
- Decision: The retry window is evaluated from the first complete Rolemaster skill result, after the original expression modifier and any low-end subtraction chain are applied, but before any retry bonus is applied.
|
||||
Rationale: The request speaks about “if the result is ...” rather than about a raw trigger die. Using the user-visible result keeps the rule understandable in the UI and in tests.
|
||||
Date/Author: 2026-04-04 / Codex
|
||||
- Decision: non-zero situational modifiers will be accepted only for Rolemaster skill rolls. Non-Rolemaster skill rolls will continue to execute immediately without showing the modal, and the server will reject any accidental non-zero modifier sent for another ruleset.
|
||||
Rationale: the user asked for this behavior only for the Rolemaster system. The server-side guard prevents future UI regressions from silently broadening the feature.
|
||||
Date/Author: 2026-04-14 / Codex
|
||||
|
||||
- Decision: An eligible roll triggers exactly one automatic retry. The retry does not recurse again even if the retry result also lands in a retry band.
|
||||
Rationale: Recursive retries would make the feature hard to explain, hard to test, and far removed from the user’s “automatic retry” request.
|
||||
Date/Author: 2026-04-04 / Codex
|
||||
- Decision: the modal input will be stored as raw text in UI state and parsed on confirm rather than bound directly to an `int`.
|
||||
Rationale: blank must mean zero, signed values must be easy to type, and raw text avoids awkward intermediate states such as a lone `-` while the user is editing.
|
||||
Date/Author: 2026-04-14 / Codex
|
||||
|
||||
- Decision: The final stored roll result becomes the retried result plus the retry bonus, while the original skipp result remains visible in the breakdown and log summary.
|
||||
Rationale: An automatic retry should materially change the outcome, not merely annotate the failed first attempt. Keeping the first attempt visible preserves auditability.
|
||||
Date/Author: 2026-04-04 / Codex
|
||||
- Decision: Rolemaster breakdown strings will show the base skill modifier and the one-shot situational modifier as separate visible terms instead of folding them into one combined number.
|
||||
Rationale: the user needs to audit why a retry happened. `8+50+20=78` is clearer than `8+70=78`, especially when comparing the stored skill expression with the one-time adjustment.
|
||||
Date/Author: 2026-04-14 / Codex
|
||||
|
||||
- Decision: compact log badges do not need a new “situational modifier” badge.
|
||||
Rationale: the result number already changes, the detailed breakdown will show the exact temporary modifier, and adding a new badge for a one-shot value would create clutter with little value.
|
||||
Date/Author: 2026-04-14 / Codex
|
||||
|
||||
## Outcomes & Retrospective
|
||||
|
||||
Implementation has not started yet. Success for this plan means that a user can enable the toggle on a Rolemaster open-ended skill, produce a first result in the retry band, see the final roll automatically retried with the correct bonus, and recognize that special case directly in the log card without opening the detail view.
|
||||
The feature is now complete end to end. Rolemaster skill rolls no longer execute immediately from the play screen; they first open a modal that accepts an optional one-shot situational modifier, focuses the input automatically, closes on Escape or backdrop click, and validates whole-number input inline. Confirmed rolls send the temporary modifier through the existing skill-roll API, Rolemaster breakdowns show the base and situational modifiers as separate terms, and automatic retry math reuses the same situational modifier on both attempts. Service, API, and Playwright coverage now prove the backend math, the Rolemaster-only guard, and the browser interaction flow.
|
||||
|
||||
## Context and Orientation
|
||||
|
||||
`RpgRoller` is an ASP.NET Core plus Blazor Server application. The gameplay state lives in memory as plain domain objects under `RpgRoller/Domain`, is persisted through EF Core and SQLite under `RpgRoller/Data` and `RpgRoller/Hosting`, and is exposed through Minimal API endpoints under `RpgRoller/Api`. Blazor UI components under `RpgRoller/Components` render the authenticated workspace and call those APIs.
|
||||
This repository is an ASP.NET Core and Blazor application rooted at `D:\Code\RpgRoller`. The user-facing play screen is assembled in `RpgRoller/Components/Pages/Workspace.razor`. That page wires `CharacterPanel` to the coordinator class `RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs`, which currently handles skill rolls by calling `Play.RollSkillAsync`.
|
||||
|
||||
The Rolemaster roll implementation is isolated well enough that this feature can be added without disturbing D6 or D&D 5e. `RpgRoller/Services/RolemasterRollEngine.cs` currently handles both ordinary Rolemaster rolls and open-ended percentile rolls. `RpgRoller/Services/RollEngine.cs` dispatches by ruleset. `RpgRoller/Services/GameRollService.cs` records rolls, persists them, and builds campaign-log list entries.
|
||||
The skill list and its dice buttons live in `RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor`. Each button currently emits only the `Guid` skill identifier. `RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor` and `CharacterPanel.razor.cs` forward that identifier upward through the `RollRequested` callback without opening any pre-roll UI.
|
||||
|
||||
Skill configuration flows through several layers. The domain model types are `RpgRoller/Domain/GameModels.cs`. EF Core maps them in `RpgRoller/Data/RpgRollerDbContext.cs`. API request and response records live in `RpgRoller/Contracts/ApiContracts.cs`. The backend service methods that validate and save skill edits live in `RpgRoller/Services/GameSkillService.cs` and `RpgRoller/Services/SkillDefinitionValidator.cs`. Blazor form state models live in `RpgRoller/Components/Pages/Home.Models.cs`. The create and edit skill modal lives in `RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor` and `.razor.cs`. Character-panel group editing lives in `RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor` and `.razor.cs`.
|
||||
The request contract for a skill roll lives in `RpgRoller/Contracts/ApiContracts.cs` as `RollSkillRequest`. The HTTP endpoint is `POST /api/skills/{skillId}/roll` in `RpgRoller/Api/SkillEndpoints.cs`. The service contract is `IGameService.RollSkill(...)` in `RpgRoller/Services/IGameService.cs`, implemented by `RpgRoller/Services/GameService.cs`, and executed by `RpgRoller/Services/GameRollService.cs`.
|
||||
|
||||
Campaign log cards are compact list entries, not full detail records. The compact card summary text and badge codes are composed in `RpgRoller/Services/CampaignLogSummaryBuilder.cs` and rendered in `RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor` and `.razor.cs`. Roll detail dice chips are rendered by `RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs`. If retry metadata needs to survive app restarts, it must either be encoded in the persisted dice JSON or in the persisted breakdown string because `RollLogEntry` currently stores only `Result`, `Breakdown`, and serialized `Dice`.
|
||||
Rolemaster rolling behavior is implemented in `RpgRoller/Services/RollEngine.cs`, `RolemasterRollEngine.cs`, `RolemasterRetryPolicy.cs`, and `RollBreakdownFormatter.cs`. A “situational modifier” in this plan means a temporary integer that is added to or subtracted from the stored skill expression for one roll only. The stored skill expression is still the canonical thing saved on the skill, such as `d100!+50`. The situational modifier exists only in the request that triggers one roll and in the recorded breakdown text that explains that roll afterward.
|
||||
|
||||
UI and regression coverage already exist in the repository areas that matter here. `RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs` covers Rolemaster engine behavior. `RpgRoller.Tests/Api/RolemasterApiTests.cs` covers Rolemaster HTTP behavior. `tests/e2e/smoke.spec.js` covers the browser play flow and already contains Rolemaster smoke tests, including the automatic retry badge path. These are the places to extend rather than creating disconnected new test files.
|
||||
|
||||
## Plan of Work
|
||||
|
||||
Start by extending the skill model so the retry toggle has a place to live. Add a non-nullable Boolean property named `RolemasterRetryOnSkipp` to `Skill` in `RpgRoller/Domain/GameModels.cs`. Thread that property through the DTO surface in `RpgRoller/Contracts/ApiContracts.cs` by extending `CreateSkillRequest`, `UpdateSkillRequest`, `SkillSummary`, and `CharacterSheetSkill`. Keep skill groups unchanged. Update `RpgRoller/Services/GameDtoMapper.cs` so summaries and character sheets include the new flag. Update cloning or state-copy helpers that copy `Skill` objects, including `RpgRoller/Services/GameStateCloneFactory.cs`, so the flag persists through load/save cycles.
|
||||
Start by widening the skill-roll request path, but keep the feature transient. In `RpgRoller/Contracts/ApiContracts.cs`, change `RollSkillRequest` so it carries both `Visibility` and `SituationalModifier`, defaulting the latter to `0`. Update `RpgRoller/Contracts/RpgRollerJsonSerializerContext.cs` only if the source generator needs to reflect the changed shape. Then thread the new integer through `RpgRoller/Api/SkillEndpoints.cs`, `RpgRoller/Services/IGameService.cs`, `RpgRoller/Services/GameService.cs`, and `RpgRoller/Services/GameRollService.cs`. In `GameRollService.RollSkill`, resolve the campaign and parsed expression exactly as today, then reject a non-zero situational modifier unless the campaign ruleset is Rolemaster. Reuse the existing authorization and visibility checks unchanged. No database or domain model changes are needed for this part.
|
||||
|
||||
Add a database migration for the new column. Update `RpgRoller/Data/RpgRollerDbContext.cs` so EF Core maps `RolemasterRetryOnSkipp` as a required Boolean with a default value of `false`. Generate a migration under `RpgRoller/Migrations` that adds the column to `Skills` with a safe default. Do not widen this migration to unrelated schema changes. If existing migration coverage fixtures or history assertions mention the newest migration id, update them so startup migration tests remain accurate.
|
||||
After the request pipeline can accept the modifier, update the Rolemaster execution path so the temporary value participates in the actual roll math rather than being bolted on afterward. The cleanest repository-local shape is to extend `RollEngine.Roll(...)` and `RolemasterRollEngine.Roll(...)` with an optional `situationalModifier = 0` argument. Only the Rolemaster branch should consume it. In `RolemasterRollEngine`, add the situational modifier to the expression modifier for both standard Rolemaster rolls and open-ended percentile attempts. The first attempt total that feeds `RolemasterRetryPolicy.ResolveAutoRetryBonus(...)` must already include both the stored skill modifier and the situational modifier. The retry attempt must use the same situational modifier again. Update `RollBreakdownFormatter` so Rolemaster text remains explicit, for example `08+50+20=78` for a normal positive path and `(05) -97 +50 +20 = -22` for a low-end path. The retry breakdown must also preserve this explicit style, for example `08+50+20=78; retry(+5): 42+50+20=112; final=117`.
|
||||
|
||||
Once the property exists, tighten validation. Extend `RpgRoller/Services/SkillDefinitionValidator.cs` so its return value includes the retry flag. Validation must accept `RolemasterRetryOnSkipp = true` only when the ruleset is Rolemaster and the parsed expression kind is `RolemasterOpenEndedPercentile`. For every other ruleset or expression kind, the backend must reject `true` with a specific validation error such as `invalid_rolemaster_retry`. When the flag is false, behavior must remain unchanged. Update `RpgRoller/Services/GameSkillService.cs`, `RpgRoller/Services/IGameService.cs`, and `RpgRoller/Api/SkillEndpoints.cs` so skill creation and update calls carry the extra argument all the way through.
|
||||
Then add the pre-roll modal to the workspace play flow. Keep the state and orchestration in the existing play coordinator rather than letting `CharacterPanel` call the API itself. Change the roll callback path from `Guid` to `CharacterSheetSkill` so the coordinator has the skill name and expression immediately. Update `RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor(.cs)` and `CharacterPanel.razor(.cs)` to pass the full skill object upward. In `RpgRoller/Components/Pages/WorkspaceState.cs`, add modal state for whether the prompt is open, which skill is pending, the pending raw modifier text, whether the modal is submitting, and the current validation message. In `RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs`, replace the direct-roll entry point with a Rolemaster-aware method that either opens the modal or immediately rolls with modifier `0` for other rulesets. Add companion methods to confirm and cancel the pending Rolemaster roll.
|
||||
|
||||
Implement the retry rule in a dedicated helper instead of burying threshold math inside the roll engine. Add a new backend helper file, for example `RpgRoller/Services/RolemasterRetryPolicy.cs`, with a small API such as `public static int? ResolveSkippRetryBonus(int firstResult)`. This helper must return `5`, `10`, or `null` according to the exact windows described earlier. Put the thresholds here so both tests and the roll engine read the same source of truth.
|
||||
Render the modal near the bottom of `RpgRoller/Components/Pages/Workspace.razor`, alongside the existing character modals, so it shares the same page-level ownership as other overlays. Create a dedicated component pair at `RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor` and `RolemasterSkillRollModal.razor.cs` instead of expanding `CharacterPanel` further. The modal should show the skill name, the stored expression, a short help line explaining that blank means zero and negative numbers are allowed, and a single signed-number input. The input should autofocus via `ElementReference.FocusAsync()`. Escape should cancel. Enter should submit through the form. Clicking the overlay outside the dialog card should cancel, while clicking inside the card must not bubble out. Use the existing `.modal-overlay` and `.modal-card` styling patterns first; only add CSS in `RpgRoller/wwwroot/styles.css` if the modal needs a small amount of spacing or width tuning.
|
||||
|
||||
Then extend the Rolemaster roll engine. Change `RpgRoller/Services/RollEngine.cs` so Rolemaster rolls can receive the new per-skill toggle. Change `RpgRoller/Services/RolemasterRollEngine.cs` so open-ended percentile rolls do the following: compute the first attempt exactly as today; evaluate `RolemasterRetryPolicy.ResolveSkippRetryBonus(firstResult)`; if the toggle is off or the policy returns `null`, return the original result unchanged; otherwise perform one more full roll of the same parsed expression, calculate that second attempt’s result exactly as a normal Rolemaster open-ended roll, add the retry bonus to that second attempt, and store that number as the final roll result.
|
||||
Validation belongs in both UI and server code. The modal should trim whitespace and treat an empty field as `0`. If parsing fails, keep the modal open and show an inline error such as “Enter a whole number like 20, -15, or leave blank for 0.” On the server, reject values outside the same Rolemaster modifier limits already enforced by `DiceRules`, namely `-1000` through `1000`. Reuse the existing API error path so invalid requests still surface as user-facing errors without breaking the page.
|
||||
|
||||
Make the retry understandable in persisted output. Introduce an optional `Attempt` property on `RollDieResult` in `RpgRoller/Contracts/ApiContracts.cs` so dice from the original roll can be tagged as attempt `1` and retry dice as attempt `2`. Keep the existing `Sequence` property semantics within each attempt. Update `RolemasterRollEngine` to set `Attempt = 1` for the original dice and `Attempt = 2` for retry dice. Update `RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs` so the generated title text mentions “attempt 1” or “retry attempt” when `Attempt` is present. This keeps the detail view legible without redesigning the dice strip layout.
|
||||
|
||||
The breakdown string must become parseable and human-readable. Extend `RpgRoller/Services/RollBreakdownFormatter.cs` with a formatter dedicated to retry output. Preserve the current breakdown text for each individual attempt, then combine them into a single breakdown string shaped like:
|
||||
|
||||
<first-attempt-breakdown>; retry(+5): <retry-attempt-breakdown>; final=<final-result>
|
||||
|
||||
or
|
||||
|
||||
<first-attempt-breakdown>; retry(+10): <retry-attempt-breakdown>; final=<final-result>
|
||||
|
||||
This format is intentionally simple. It is readable in the UI, survives persistence without new tables, and gives `CampaignLogSummaryBuilder` a stable marker to detect retry badges later. Do not change non-retry breakdown formatting.
|
||||
|
||||
Update the compact campaign-log helpers to surface the new special result. `RpgRoller/Services/CampaignLogSummaryBuilder.cs` should accept the stored breakdown when building badges and compact summaries. Add badge codes `rs5` and `rs10` for “Retry +5” and “Retry +10”. When a retry marker is present in the breakdown, append a short retry note to the Rolemaster compact summary, for example `| retry +5` or `| retry +10`, while keeping existing `rf`, `r66`, and `r100` behavior intact. Update `RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs` so those new badge codes render as visible labels on the log card.
|
||||
|
||||
After the backend shape is stable, wire the UI toggle. Extend `SkillFormModel` in `RpgRoller/Components/Pages/Home.Models.cs` with `RolemasterRetryOnSkipp`. Update `SkillFormModal.razor.cs` so the form copies the value from `InitialModel`, validates it only for Rolemaster open-ended expressions, and clears it automatically when the skill expression becomes invalid for retry. Update `SkillFormModal.razor` to show a checkbox only in the Rolemaster open-ended branch, near the fumble-range input, with concise help text that explains the exact windows. Update `CharacterPanel.razor.cs` so create and edit skill dialogs pass the property in their initial models and request payloads. Do not add a corresponding group-level control.
|
||||
|
||||
Expose the setting in the workspace read model so the user can see it again after save. Extend `WorkspaceState.SkillDefinitionLabel(...)` and `RulesetFormHelpers.DescribeRolemasterExpression(...)` as needed so a Rolemaster open-ended skill with retry enabled renders a label that includes the retry rule in compact form, for example `Open-ended percentile: d100!+15, fumble <= 5, retry skipp`. Keep the label short enough that existing layout remains intact.
|
||||
|
||||
Finally, update documentation. `README.md` must describe the new Rolemaster skill option, the retry windows, and the fact that the campaign log now shows retry badges. If an example command or screenshot-free narrative is needed, keep it textual and current rather than writing a historical change note.
|
||||
|
||||
## Milestones
|
||||
|
||||
### Milestone 1: Persist and validate the skill toggle
|
||||
|
||||
At the end of this milestone, the repository can create, read, update, persist, and reload a Rolemaster skill with the retry flag, but no roll behavior changes yet. The new property exists in the domain model, EF Core schema, API contracts, service signatures, DTOs, and Blazor form state. Validation rejects impossible combinations such as D6 plus retry enabled or Rolemaster `d10` plus retry enabled.
|
||||
|
||||
Run the service and API tests that cover skill validation and persistence from `D:\Code\RpgRoller`:
|
||||
|
||||
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --filter "FullyQualifiedName~ServiceHelperExtractionTests|FullyQualifiedName~ServicePersistenceTests|FullyQualifiedName~WorkspaceQueryServiceTests|FullyQualifiedName~CampaignApiTests"
|
||||
|
||||
Acceptance is that new tests prove the flag survives round trips and that stale invalid values are rejected before any roll logic is touched.
|
||||
|
||||
### Milestone 2: Execute and record automatic retry
|
||||
|
||||
At the end of this milestone, an eligible Rolemaster open-ended skill roll automatically performs one retry and stores a final result based on the retry attempt plus the policy bonus. Non-eligible or disabled skills still behave exactly as before. The detailed breakdown string explains the first attempt, the retry bonus, the retry attempt, and the final result. The detail dice strip distinguishes original and retry attempts through titles.
|
||||
|
||||
Run targeted Rolemaster service and API tests from `D:\Code\RpgRoller`:
|
||||
|
||||
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --filter "FullyQualifiedName~ServiceRolemasterRollTests|FullyQualifiedName~RolemasterApiTests|FullyQualifiedName~PayloadBudgetTests"
|
||||
|
||||
Acceptance is that there are explicit tests for a `+5` retry case, a `+10` retry case, and a disabled-skill case that proves the old result path remains unchanged.
|
||||
|
||||
### Milestone 3: Surface the retry in the workspace and lock the behavior
|
||||
|
||||
At the end of this milestone, the create and edit skill UI exposes the toggle only when valid, saved skills show the toggle again when reopened, log cards display retry badges, and browser smoke coverage proves the experience without manual clicking. Documentation is updated and the full local parity script passes.
|
||||
|
||||
Run from `D:\Code\RpgRoller`:
|
||||
|
||||
pwsh ./scripts/ci-local.ps1
|
||||
|
||||
Acceptance is that the run finishes successfully, the new browser test proves the checkbox and badge behavior, and no unrelated payload-budget or smoke-test regressions appear.
|
||||
Finally, update documentation and tests. `README.md` must describe the new Rolemaster roll flow in current-state language, not as a changelog note. Extend service tests to prove that a situational modifier changes Rolemaster totals, triggers retry evaluation from the combined total, and applies again on the retry attempt. Extend API tests to prove the new request payload, server-side Rolemaster-only validation, and breakdown text. Extend Playwright smoke coverage so the browser proves the modal opens only on Rolemaster skill rolls, autofocus works, Enter submits, Escape and outside click dismiss, invalid text stays inline, and a positive situational bonus can be used to cause a retry-enabled result.
|
||||
|
||||
## Concrete Steps
|
||||
|
||||
Work from `D:\Code\RpgRoller`.
|
||||
All commands below run from `D:\Code\RpgRoller`.
|
||||
|
||||
1. Edit the domain model and contracts first so every later compiler error points toward the remaining call sites. Update `RpgRoller/Domain/GameModels.cs`, `RpgRoller/Contracts/ApiContracts.cs`, `RpgRoller/Services/GameDtoMapper.cs`, `RpgRoller/Services/GameStateCloneFactory.cs`, `RpgRoller/Services/IGameService.cs`, `RpgRoller/Services/GameSkillService.cs`, and `RpgRoller/Api/SkillEndpoints.cs`.
|
||||
First, implement the request and engine changes, then run targeted tests while the work is still small.
|
||||
|
||||
2. Add the EF Core schema update in `RpgRoller/Data/RpgRollerDbContext.cs`, generate the migration in `RpgRoller/Migrations`, and update any migration-history assertions in `RpgRoller.Tests/HostingCoverageTests.cs` if they depend on the latest migration name.
|
||||
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --filter "FullyQualifiedName~ServiceRolemasterRollTests|FullyQualifiedName~RolemasterApiTests"
|
||||
|
||||
3. Add backend validation and retry policy code. Extend `RpgRoller/Services/SkillDefinitionValidator.cs`. Add `RpgRoller/Services/RolemasterRetryPolicy.cs`. Update `RpgRoller/Services/RollEngine.cs`, `RpgRoller/Services/RolemasterRollEngine.cs`, and `RpgRoller/Services/RollBreakdownFormatter.cs`.
|
||||
After the UI modal is in place, run the browser smoke suite directly.
|
||||
|
||||
4. Update compact log helpers and UI consumers. Change `RpgRoller/Services/CampaignLogSummaryBuilder.cs`, `RpgRoller/Services/GameRollService.cs`, `RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs`, and `RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs`.
|
||||
pwsh ./scripts/run-playwright.ps1
|
||||
|
||||
5. Update form models and skill forms. Change `RpgRoller/Components/Pages/Home.Models.cs`, `RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor`, `RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs`, `RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs`, `RpgRoller/Components/Pages/WorkspaceState.cs`, and `RpgRoller/Components/Pages/HomeControls/RulesetFormHelpers.cs`.
|
||||
Before closing the iteration, format every touched file using the repository rule. Replace the placeholder list with the exact touched file paths separated by semicolons.
|
||||
|
||||
6. Add or update tests before running the full parity script. The expected primary test files are `RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs`, `RpgRoller.Tests/Services/ServiceHelperExtractionTests.cs`, `RpgRoller.Tests/Services/ServicePersistenceTests.cs`, `RpgRoller.Tests/Services/ServiceRollHelperTests.cs`, `RpgRoller.Tests/Services/WorkspaceStateTests.cs`, `RpgRoller.Tests/Api/RolemasterApiTests.cs`, and `tests/e2e/smoke.spec.js`.
|
||||
jb cleanupcode --build=False RpgRoller/Contracts/ApiContracts.cs;RpgRoller/Api/SkillEndpoints.cs;RpgRoller/Services/IGameService.cs;RpgRoller/Services/GameService.cs;RpgRoller/Services/GameRollService.cs;RpgRoller/Services/RollEngine.cs;RpgRoller/Services/RolemasterRollEngine.cs;RpgRoller/Services/RollBreakdownFormatter.cs;RpgRoller/Components/Pages/Workspace.razor;RpgRoller/Components/Pages/WorkspaceState.cs;RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs;RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor;RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs;RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor;RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor.cs;RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor;RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor.cs;RpgRoller/wwwroot/styles.css;RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs;RpgRoller.Tests/Api/RolemasterApiTests.cs;tests/e2e/smoke.spec.js;README.md
|
||||
|
||||
7. Update `README.md`, run the full local parity script, inspect `git status`, and commit with a brief message only after all validations pass.
|
||||
|
||||
Expected final command transcript:
|
||||
|
||||
==> Run tests
|
||||
Passed! - Failed: 0, Passed: <updated count>, Skipped: 0, Total: <updated count>
|
||||
==> Enforce coverage thresholds
|
||||
Line coverage: <at least 90%>
|
||||
Branch coverage: <at least 70%>
|
||||
==> Run Playwright smoke test
|
||||
<smoke tests all pass>
|
||||
CI checks passed.
|
||||
|
||||
## Validation and Acceptance
|
||||
|
||||
Validation is complete only when all of the following are true.
|
||||
|
||||
The backend proves rule correctness. There must be a unit test where the first attempt result is `78` and the stored final result comes from a retry with `+5`. There must be another where the first attempt result is `96` or another value inside the second band and the stored final result comes from a retry with `+10`. There must be a test where the skill toggle is disabled and an otherwise eligible first result still does not retry.
|
||||
|
||||
The persistence layer proves round-trip safety. A test must create a skill with the toggle enabled, persist the database, reload state, and confirm the skill still exposes `RolemasterRetryOnSkipp = true`.
|
||||
|
||||
The API layer proves contract shape. A test must create and update a Rolemaster open-ended skill through HTTP and confirm the toggle round-trips through `SkillSummary`, `CharacterSheet`, and roll results. Invalid combinations must return a concrete API error rather than silently coercing the value.
|
||||
|
||||
The compact log proves user-visible behavior. A service or API test must show that a retried roll produces `rs5` or `rs10` in the log entry badges and that the summary text includes a retry marker. A browser test must create or use a retry-enabled Rolemaster skill, roll it, and verify that the log card shows the retry badge without expanding the detail row first.
|
||||
|
||||
The whole repo must still pass the local parity script:
|
||||
Run the repository-wide local CI script as the final proof.
|
||||
|
||||
pwsh ./scripts/ci-local.ps1
|
||||
|
||||
Then create one brief commit for the iteration.
|
||||
|
||||
git add TASKS.md README.md RpgRoller/Contracts/ApiContracts.cs RpgRoller/Api/SkillEndpoints.cs RpgRoller/Services/IGameService.cs RpgRoller/Services/GameService.cs RpgRoller/Services/GameRollService.cs RpgRoller/Services/RollEngine.cs RpgRoller/Services/RolemasterRollEngine.cs RpgRoller/Services/RollBreakdownFormatter.cs RpgRoller/Components/Pages/Workspace.razor RpgRoller/Components/Pages/WorkspaceState.cs RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor.cs RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor.cs RpgRoller/wwwroot/styles.css RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs RpgRoller.Tests/Api/RolemasterApiTests.cs tests/e2e/smoke.spec.js
|
||||
git commit -m "Add Rolemaster situational roll modifier prompt"
|
||||
|
||||
Expected proof points during implementation are:
|
||||
|
||||
A Rolemaster skill such as d100!+50 opens the modal instead of rolling immediately.
|
||||
Leaving the field blank and pressing Enter records a normal roll.
|
||||
Typing 20 and pressing Enter records a breakdown that visibly contains +20.
|
||||
If the first attempt becomes 76 through 110 after adding the situational modifier, the existing retry flow still fires.
|
||||
A D6 or D&D 5e skill still rolls immediately with no popup.
|
||||
|
||||
## Validation and Acceptance
|
||||
|
||||
Acceptance is behavioral, not just “the code compiles.”
|
||||
|
||||
Start the browser smoke environment with `pwsh ./scripts/run-playwright.ps1` or run the application locally and navigate to the play screen. Create or seed a Rolemaster campaign, a character, and an open-ended skill such as `Observation` with `d100!+50`, `fumbleRange: 5`, and `rolemasterAutoRetry: true`.
|
||||
|
||||
On the play screen, clicking `Roll Observation` must open a modal dialog. The modifier input must receive focus immediately. Pressing Escape must close the dialog with no roll recorded. Clicking the backdrop outside the dialog must also close it with no roll recorded. Reopening the dialog and pressing Enter with the field blank must submit a normal zero-modifier roll.
|
||||
|
||||
Reopen the dialog and enter `20`. After confirming, the recorded roll detail must show the temporary modifier as a separate term in the breakdown. If the first computed attempt lands in the retry window, the breakdown must show the same `+20` in both attempts and the final total must reflect the retry bonus on top of the retry attempt. The compact log entry should still show the existing retry badge behavior, and expanding the detail should show the existing attempt markers on the dice chips.
|
||||
|
||||
Run `dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj` and expect the suite to pass. The new or updated tests should fail before the feature is implemented and pass after it is complete. Run `pwsh ./scripts/ci-local.ps1` and expect the build, tests, coverage gate, and Playwright smoke test to pass end-to-end.
|
||||
|
||||
## Idempotence and Recovery
|
||||
|
||||
This plan is safe to execute incrementally. Compiler errors after the first contract changes are expected because the repository is strongly typed; fix the next call site rather than backing out partial edits.
|
||||
This feature should be implemented additively and is safe to retry. Re-running the same code-edit steps should only replace the intended current-state logic. No migration is expected for this feature because the modifier is transient. If a draft implementation accidentally introduces persistence for the modifier, remove that persistence before considering the work complete.
|
||||
|
||||
If the EF Core migration step is blocked because the development app or tests are holding the SQLite file open, stop the running `dotnet` process, retry the migration command once, and continue. This repo explicitly allows that recovery step after database changes.
|
||||
|
||||
If a partial implementation leaves the feature half-wired, the safe recovery path is to keep the new property and validation in place, set the toggle to default `false`, and continue forward until tests pass. Do not delete unrelated user changes from the worktree to recover.
|
||||
If the UI modal gets into a bad state during development, the safe recovery path is to clear only the workspace prompt state in `WorkspaceState` and retry the interaction. If Playwright fails because the temporary application process is still running, stop the lingering `dotnet` process once, rerun the script, and confirm the health endpoint responds before rechecking the smoke suite.
|
||||
|
||||
## Artifacts and Notes
|
||||
|
||||
The key code paths to revisit while implementing are these:
|
||||
The most important visible transcript to preserve during implementation is the breakdown text for a retry-causing situational bonus. A representative successful result should look like this shape, with different random numbers allowed:
|
||||
|
||||
RpgRoller/Services/RolemasterRollEngine.cs
|
||||
RpgRoller/Services/CampaignLogSummaryBuilder.cs
|
||||
RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs
|
||||
RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs
|
||||
08+50+20=78; retry(+5): 42+50+20=112; final=117
|
||||
|
||||
The intended retry badge mapping is:
|
||||
The corresponding browser-level proof should include a log row that shows the final result, retains the existing retry badge, and expands into detail whose dice titles still include attempt markers such as:
|
||||
|
||||
rs5 -> Retry +5
|
||||
rs10 -> Retry +10
|
||||
|
||||
The intended breakdown marker is:
|
||||
|
||||
; retry(+5):
|
||||
; retry(+10):
|
||||
|
||||
These marker strings are chosen so they can be detected cheaply without adding another persisted table or JSON blob.
|
||||
Roll 8, step 1, attempt 1, Rolemaster open-ended initial
|
||||
Roll 42, step 1, retry attempt 2, Rolemaster open-ended initial
|
||||
|
||||
## Interfaces and Dependencies
|
||||
|
||||
At the end of the implementation, these interfaces and shapes must exist.
|
||||
At the end of the implementation, these repository interfaces should exist in the following shapes.
|
||||
|
||||
In `RpgRoller/Domain/GameModels.cs`, `Skill` must expose:
|
||||
In `RpgRoller/Contracts/ApiContracts.cs`, define:
|
||||
|
||||
public bool RolemasterRetryOnSkipp { get; set; }
|
||||
public sealed record RollSkillRequest(string Visibility, int SituationalModifier = 0);
|
||||
|
||||
In `RpgRoller/Contracts/ApiContracts.cs`, these records must include the new Boolean:
|
||||
In `RpgRoller/Services/IGameService.cs`, define:
|
||||
|
||||
public sealed record CreateSkillRequest(..., int? FumbleRange = null, bool RolemasterRetryOnSkipp = false);
|
||||
public sealed record UpdateSkillRequest(..., int? FumbleRange = null, bool RolemasterRetryOnSkipp = false);
|
||||
public sealed record SkillSummary(..., int? FumbleRange, bool RolemasterRetryOnSkipp);
|
||||
public sealed record CharacterSheetSkill(..., int? FumbleRange, bool RolemasterRetryOnSkipp);
|
||||
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier = 0);
|
||||
|
||||
`RollDieResult` must gain an optional attempt marker:
|
||||
In `RpgRoller/Services/GameRollService.cs`, define a matching method:
|
||||
|
||||
public int? Attempt { get; init; }
|
||||
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier)
|
||||
|
||||
In `RpgRoller/Services/RolemasterRetryPolicy.cs`, define:
|
||||
In `RpgRoller/Services/RollEngine.cs`, extend the Rolemaster path with:
|
||||
|
||||
public static int? ResolveSkippRetryBonus(int firstResult)
|
||||
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(
|
||||
RulesetKind ruleset,
|
||||
DiceExpression expression,
|
||||
int wildDice,
|
||||
bool allowFumble,
|
||||
int? fumbleRange,
|
||||
bool rolemasterAutoRetry = false,
|
||||
int situationalModifier = 0)
|
||||
|
||||
In `RpgRoller/Services/SkillDefinitionValidator.cs`, the validation result tuple must carry the retry flag so backend callers do not need to re-derive validity from raw request data.
|
||||
In `RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor.cs` and `CharacterPanel.razor.cs`, change the roll callback shape to carry the full skill:
|
||||
|
||||
In `RpgRoller/Services/RollEngine.cs`, the Rolemaster dispatch signature must carry the skill toggle through to `RolemasterRollEngine`.
|
||||
[Parameter]
|
||||
public EventCallback<CharacterSheetSkill> RollSkillRequested { get; set; }
|
||||
|
||||
In `RpgRoller/Services/RolemasterRollEngine.cs`, the open-ended roll path must still return:
|
||||
[Parameter]
|
||||
public EventCallback<CharacterSheetSkill> RollRequested { get; set; }
|
||||
|
||||
(int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice)
|
||||
Create a new modal component at `RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor` and `.razor.cs` that accepts at least the visibility flag, skill label, expression label, raw modifier text, submit state, and confirm/cancel callbacks. The component should use only existing Blazor and repository dependencies; no third-party modal library is required or desired.
|
||||
|
||||
but it must now build that tuple from one or two attempts depending on the retry policy.
|
||||
|
||||
Revision note: created this ExecPlan on 2026-04-04 because the user requested a repository-local execution plan in `TASKS.md` before implementation.
|
||||
Plan revision note (2026-04-14 / Codex): created the initial ExecPlan for the new Rolemaster one-shot situational modifier modal because `TASKS.md` was empty and the feature needs a self-contained implementation guide before coding begins.
|
||||
Plan revision note (2026-04-14 / Codex): updated the living plan after the first backend slice landed so progress, discoveries, and retrospective match the new transient request path, explicit breakdown formatting, and added service/API coverage.
|
||||
Plan revision note (2026-04-14 / Codex): updated the living plan again after the UI slice completed so the document now reflects the shipped modal behavior, the Playwright coverage, and the final end-to-end outcome.
|
||||
|
||||
@@ -89,6 +89,107 @@ test("Rolemaster open-ended roll detail renders specialized dice chips", async (
|
||||
await expect(page.locator(".log-detail .roll-dice-strip")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Rolemaster automatic retry badge shows before detail expands", async ({ page, context }) => {
|
||||
const username = `rm-retry-${Date.now()}`;
|
||||
await registerAndLogin(context.request, username, "Rolemaster Retry Smoke");
|
||||
|
||||
const campaign = await postJson(context.request, "/api/campaigns", {
|
||||
name: "Rolemaster Retry Smoke",
|
||||
rulesetId: "rolemaster"
|
||||
});
|
||||
const character = await postJson(context.request, "/api/characters", {
|
||||
name: "Retry Hero",
|
||||
campaignId: campaign.id
|
||||
});
|
||||
const skill = await postJson(context.request, `/api/characters/${character.id}/skills`, {
|
||||
name: "Retry Sight",
|
||||
diceRollDefinition: "d100!+10",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 5,
|
||||
rolemasterAutoRetry: true
|
||||
});
|
||||
|
||||
let retriedRoll = null;
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
const roll = await postJson(context.request, `/api/skills/${skill.id}/roll`, { visibility: "public" });
|
||||
if (roll.breakdown.includes("retry(+")) {
|
||||
retriedRoll = roll;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(retriedRoll, "expected a retry-enabled Rolemaster roll within 10 attempts").not.toBeNull();
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
|
||||
const retryEntry = page.locator(".log-panel .log-entry").filter({ hasText: "retry +" }).last();
|
||||
await expect(retryEntry).toBeVisible();
|
||||
await expect(retryEntry.locator(".log-event-badge")).toContainText([/Retry \+(5|10)/]);
|
||||
await expect(retryEntry.locator(".log-summary-text")).toContainText(/retry \+(5|10)/);
|
||||
await expect(retryEntry.locator(".log-detail")).toHaveCount(0);
|
||||
|
||||
await retryEntry.locator(".log-entry-toggle").click();
|
||||
const detailDice = retryEntry.locator(".log-detail .die-chip");
|
||||
await expect(detailDice).toHaveCount(2);
|
||||
await expect(detailDice.nth(0)).toHaveAttribute("title", /attempt 1/i);
|
||||
await expect(detailDice.nth(1)).toHaveAttribute("title", /retry attempt 2/i);
|
||||
});
|
||||
|
||||
test("Rolemaster skill roll modal autofocuses, validates, and closes on escape or backdrop", async ({ page, context }) => {
|
||||
const username = `rm-modal-${Date.now()}`;
|
||||
await registerAndLogin(context.request, username, "Rolemaster Modal Smoke");
|
||||
|
||||
const campaign = await postJson(context.request, "/api/campaigns", {
|
||||
name: "Rolemaster Modal Smoke",
|
||||
rulesetId: "rolemaster"
|
||||
});
|
||||
const character = await postJson(context.request, "/api/characters", {
|
||||
name: "Observer",
|
||||
campaignId: campaign.id
|
||||
});
|
||||
await postJson(context.request, `/api/characters/${character.id}/skills`, {
|
||||
name: "Observation",
|
||||
diceRollDefinition: "d100!+50",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 5,
|
||||
rolemasterAutoRetry: true
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
|
||||
const rollButton = page.getByRole("button", { name: "Roll Observation" });
|
||||
const modal = page.getByRole("dialog", { name: "Rolemaster situational modifier" });
|
||||
const modifierInput = page.locator("#rolemaster-situational-modifier");
|
||||
|
||||
await rollButton.click();
|
||||
await expect(modal).toBeVisible();
|
||||
await expect(modifierInput).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(modal).toHaveCount(0);
|
||||
|
||||
await rollButton.click();
|
||||
await expect(modal).toBeVisible();
|
||||
await page.locator(".modal-overlay").click({ position: { x: 8, y: 8 } });
|
||||
await expect(modal).toHaveCount(0);
|
||||
|
||||
await rollButton.click();
|
||||
await expect(modal).toBeVisible();
|
||||
await modifierInput.fill("1001");
|
||||
await modal.getByRole("button", { name: "Roll" }).click();
|
||||
await expect(page.getByText("Enter a whole number between -1000 and 1000.")).toBeVisible();
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
await modifierInput.fill("");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(modal).toHaveCount(0);
|
||||
await expect(page.locator(".log-panel .log-entry.expanded").first()).toContainText("Observation");
|
||||
});
|
||||
|
||||
test("newly rolled log entry auto-expands", async ({ page, context }) => {
|
||||
const username = `d6-log-${Date.now()}`;
|
||||
await registerAndLogin(context.request, username, "D6 Auto Expand");
|
||||
@@ -174,7 +275,8 @@ test("Rolemaster UI exposes conditional create and edit fields", async ({ page,
|
||||
diceRollDefinition: "d100!+25",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 5
|
||||
fumbleRange: 5,
|
||||
rolemasterAutoRetry: true
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
@@ -203,14 +305,27 @@ test("Rolemaster UI exposes conditional create and edit fields", async ({ page,
|
||||
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 expect(page.getByLabel("Automatic retry")).toHaveCount(0);
|
||||
await page.locator("#skill-create-expression").fill("d100!+25");
|
||||
await expect(page.locator("#skill-create-fumble-range")).toBeVisible();
|
||||
await expect(page.getByLabel("Automatic retry")).toBeVisible();
|
||||
await page.getByLabel("Automatic retry").check();
|
||||
await page.locator("#skill-create-expression").fill("d10");
|
||||
await expect(page.getByLabel("Automatic retry")).toHaveCount(0);
|
||||
await page.locator("#skill-create-expression").fill("d100!+25");
|
||||
await expect(page.getByLabel("Automatic retry")).toBeVisible();
|
||||
await expect(page.getByLabel("Automatic retry")).not.toBeChecked();
|
||||
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 expect(page.getByLabel("Automatic retry")).toBeChecked();
|
||||
await page.locator("#skill-edit-expression").fill("d10");
|
||||
await expect(page.locator("#skill-edit-fumble-range")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Automatic retry")).toHaveCount(0);
|
||||
await page.locator("#skill-edit-expression").fill("d100!+25");
|
||||
await expect(page.getByLabel("Automatic retry")).toBeVisible();
|
||||
await expect(page.getByLabel("Automatic retry")).not.toBeChecked();
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user