diff --git a/README.md b/README.md index c19daa5..0a205a2 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ Rolemaster support: - 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 +- Backend/API support for one-shot situational modifiers on Rolemaster skill rolls; 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, retry, and fumble-related events, including `Retry +5` and `Retry +10` diff --git a/RpgRoller.Tests/Api/RolemasterApiTests.cs b/RpgRoller.Tests/Api/RolemasterApiTests.cs index f5ea31c..52997a7 100644 --- a/RpgRoller.Tests/Api/RolemasterApiTests.cs +++ b/RpgRoller.Tests/Api/RolemasterApiTests.cs @@ -141,4 +141,45 @@ public sealed class RolemasterApiTests(WebApplicationFactory factory) : 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(client, "/api/campaigns", new("Rolemaster Situational", "rolemaster")); + var character = await PostAsync(client, "/api/characters", new("Hero", campaign.Id)); + var skill = await PostAsync(client, $"/api/characters/{character.Id}/skills", new("Observation", "d100!+50", 0, false, null, 5, true)); + + var roll = await PostAsync(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(client, "/api/campaigns", new("Dnd Situational", "dnd5e")); + var character = await PostAsync(client, "/api/characters", new("Hero", campaign.Id)); + var skill = await PostAsync(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(); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.NotNull(error); + Assert.Equal("invalid_situational_modifier", error.Code); + } } \ No newline at end of file diff --git a/RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs b/RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs index c325ae3..f16edfc 100644 --- a/RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs +++ b/RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs @@ -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() { @@ -213,6 +231,28 @@ public sealed class ServiceRolemasterRollTests }); } + [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(logEntry.EventBadges)); + Assert.All(roll.Dice, die => Assert.True(die.Attempt is 1 or 2)); + } + [Fact] public void RollSkill_RolemasterAutoRetryPlusTen_UsesRetryResultAndMarksAttempts() { @@ -256,4 +296,22 @@ public sealed class ServiceRolemasterRollTests 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); + } } \ No newline at end of file diff --git a/RpgRoller.Tests/Services/ServiceRollHelperTests.cs b/RpgRoller.Tests/Services/ServiceRollHelperTests.cs index 65610cc..d06d6a6 100644 --- a/RpgRoller.Tests/Services/ServiceRollHelperTests.cs +++ b/RpgRoller.Tests/Services/ServiceRollHelperTests.cs @@ -18,8 +18,11 @@ 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)); } diff --git a/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs b/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs index de9a8bf..1d1b1d6 100644 --- a/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs +++ b/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs @@ -142,7 +142,7 @@ public sealed class WorkspaceQueryServiceTests throw new NotSupportedException(); } - public ServiceResult RollSkill(string sessionToken, Guid skillId, string visibility) + public ServiceResult RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier = 0) { throw new NotSupportedException(); } diff --git a/RpgRoller/Api/SkillEndpoints.cs b/RpgRoller/Api/SkillEndpoints.cs index 7731a51..40abcc6 100644 --- a/RpgRoller/Api/SkillEndpoints.cs +++ b/RpgRoller/Api/SkillEndpoints.cs @@ -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); }); diff --git a/RpgRoller/Contracts/ApiContracts.cs b/RpgRoller/Contracts/ApiContracts.cs index cec53be..eeea9ea 100644 --- a/RpgRoller/Contracts/ApiContracts.cs +++ b/RpgRoller/Contracts/ApiContracts.cs @@ -48,7 +48,7 @@ public sealed record UpdateSkillGroupRequest(string Name, string DiceRollDefinit 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); diff --git a/RpgRoller/Services/GameRollService.cs b/RpgRoller/Services/GameRollService.cs index 255811d..dc82950 100644 --- a/RpgRoller/Services/GameRollService.cs +++ b/RpgRoller/Services/GameRollService.cs @@ -6,7 +6,7 @@ namespace RpgRoller.Services; public sealed class GameRollService(GameStateStore stateStore, GamePersistenceService persistenceService, IDiceRoller diceRoller) { - public ServiceResult RollSkill(string sessionToken, Guid skillId, string visibility) + public ServiceResult 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.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message); + if (situationalModifier != 0 && campaign.Ruleset != RulesetKind.Rolemaster) + return ServiceResult.Failure("invalid_situational_modifier", "Situational modifiers are supported only for Rolemaster skill rolls."); + + if (campaign.Ruleset == RulesetKind.Rolemaster && (situationalModifier < -MaxSituationalModifier || situationalModifier > MaxSituationalModifier)) + return ServiceResult.Failure("invalid_situational_modifier", $"Situational modifier must be between {-MaxSituationalModifier} and {MaxSituationalModifier}."); + var parsedVisibility = RollVisibilityParser.Parse(visibility); if (!parsedVisibility.Succeeded) return ServiceResult.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message); - var roll = m_RollEngine.Roll(campaign.Ruleset, parsedExpression.Value!, skill.WildDice, skill.AllowFumble, skill.FumbleRange, skill.RolemasterAutoRetry); + 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); } } @@ -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; diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index 94cffc3..a78f4ab 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -160,9 +160,9 @@ public sealed class GameService : IGameService return m_SkillService.GetCharacterSheet(sessionToken, characterId); } - public ServiceResult RollSkill(string sessionToken, Guid skillId, string visibility) + public ServiceResult 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 RollCustom(string sessionToken, Guid characterId, string expression, string visibility) diff --git a/RpgRoller/Services/IGameService.cs b/RpgRoller/Services/IGameService.cs index 23454b0..25df79b 100644 --- a/RpgRoller/Services/IGameService.cs +++ b/RpgRoller/Services/IGameService.cs @@ -36,7 +36,7 @@ public interface IGameService ServiceResult DeleteSkill(string sessionToken, Guid skillId); ServiceResult GetCharacterSheet(string sessionToken, Guid characterId); - ServiceResult RollSkill(string sessionToken, Guid skillId, string visibility); + ServiceResult RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier = 0); ServiceResult RollCustom(string sessionToken, Guid characterId, string expression, string visibility); ServiceResult> GetCampaignLog(string sessionToken, Guid campaignId); ServiceResult GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null); diff --git a/RpgRoller/Services/RolemasterRollEngine.cs b/RpgRoller/Services/RolemasterRollEngine.cs index 0c7416c..1f2a34d 100644 --- a/RpgRoller/Services/RolemasterRollEngine.cs +++ b/RpgRoller/Services/RolemasterRollEngine.cs @@ -5,20 +5,20 @@ namespace RpgRoller.Services; public sealed class RolemasterRollEngine(IDiceRoller diceRoller) { - public (int Total, string Breakdown, IReadOnlyList Dice) Roll(DiceExpression expression, int? fumbleRange, bool rolemasterAutoRetry) + public (int Total, string Breakdown, IReadOnlyList Dice) Roll(DiceExpression expression, int? fumbleRange, bool rolemasterAutoRetry, int situationalModifier = 0) { return expression.Kind switch { - DiceExpressionKind.RolemasterOpenEndedPercentile => RollOpenEnded(expression, fumbleRange.GetValueOrDefault(), rolemasterAutoRetry), - _ => RollStandard(expression) + DiceExpressionKind.RolemasterOpenEndedPercentile => RollOpenEnded(expression, fumbleRange.GetValueOrDefault(), rolemasterAutoRetry, situationalModifier), + _ => RollStandard(expression, situationalModifier) }; } - private (int Total, string Breakdown, IReadOnlyList Dice) RollStandard(DiceExpression expression, int? attempt = null) + private (int Total, string Breakdown, IReadOnlyList 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); @@ -27,17 +27,17 @@ public sealed class RolemasterRollEngine(IDiceRoller diceRoller) 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 Dice) RollOpenEnded(DiceExpression expression, int fumbleRange, bool rolemasterAutoRetry) + private (int Total, string Breakdown, IReadOnlyList Dice) RollOpenEnded(DiceExpression expression, int fumbleRange, bool rolemasterAutoRetry, int situationalModifier) { - var firstAttempt = RollOpenEndedAttempt(expression, fumbleRange); + var firstAttempt = RollOpenEndedAttempt(expression, fumbleRange, situationalModifier); var retryBonus = rolemasterAutoRetry ? RolemasterRetryPolicy.ResolveAutoRetryBonus(firstAttempt.Total) : null; if (!retryBonus.HasValue) return firstAttempt; - var retryAttempt = RollOpenEndedAttempt(expression, fumbleRange, 2); + 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(); @@ -45,7 +45,7 @@ public sealed class RolemasterRollEngine(IDiceRoller diceRoller) return (finalTotal, breakdown, dice); } - private (int Total, string Breakdown, IReadOnlyList Dice) RollOpenEndedAttempt(DiceExpression expression, int fumbleRange, int? attempt = null) + private (int Total, string Breakdown, IReadOnlyList Dice) RollOpenEndedAttempt(DiceExpression expression, int fumbleRange, int situationalModifier, int? attempt = null) { var initialRoll = diceRoller.Roll(expression.Sides); var followUpRolls = new List(); @@ -66,8 +66,8 @@ public sealed class RolemasterRollEngine(IDiceRoller diceRoller) 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); } diff --git a/RpgRoller/Services/RollBreakdownFormatter.cs b/RpgRoller/Services/RollBreakdownFormatter.cs index b8c727e..07340c7 100644 --- a/RpgRoller/Services/RollBreakdownFormatter.cs +++ b/RpgRoller/Services/RollBreakdownFormatter.cs @@ -12,15 +12,17 @@ public static class RollBreakdownFormatter } public static string BuildRolemasterOpenEndedBreakdown(int initialRoll, IReadOnlyList followUpRolls, bool subtractFollowUps, int modifier, int total) + { + return BuildRolemasterOpenEndedBreakdown(initialRoll, followUpRolls, subtractFollowUps, modifier, total, 0); + } + + public static string BuildRolemasterOpenEndedBreakdown(int initialRoll, IReadOnlyList followUpRolls, bool subtractFollowUps, int modifier, int total, int situationalModifier) { if (subtractFollowUps) { var segments = new List { $"({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,7 @@ 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) @@ -54,4 +56,40 @@ public static class RollBreakdownFormatter _ => $"{core}={total}" }; } + + public static string BuildRolemasterModifierBreakdown(IEnumerable 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 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 + }; + } } \ No newline at end of file diff --git a/RpgRoller/Services/RollEngine.cs b/RpgRoller/Services/RollEngine.cs index b6e0518..d0d0752 100644 --- a/RpgRoller/Services/RollEngine.cs +++ b/RpgRoller/Services/RollEngine.cs @@ -5,13 +5,13 @@ namespace RpgRoller.Services; public sealed class RollEngine(StandardRollEngine standardRollEngine, D6RollEngine d6RollEngine, RolemasterRollEngine rolemasterRollEngine) { - public (int Total, string Breakdown, IReadOnlyList Dice) Roll(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange, bool rolemasterAutoRetry = false) + public (int Total, string Breakdown, IReadOnlyList 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, rolemasterAutoRetry); + return rolemasterRollEngine.Roll(expression, fumbleRange, rolemasterAutoRetry, situationalModifier); return standardRollEngine.Roll(expression); } diff --git a/TASKS.md b/TASKS.md index 93ae278..91c7db7 100644 --- a/TASKS.md +++ b/TASKS.md @@ -13,18 +13,18 @@ The important user-visible rule is that this temporary modifier must be applied ## Progress - [x] (2026-04-14 21:27:50Z) Created the initial ExecPlan in `TASKS.md`, grounded in the current workspace play flow, API contract, and Rolemaster retry implementation. -- [ ] Add transient situational modifier support to the skill-roll API and service pipeline without persisting anything on `Skill` or in the database schema. +- [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. - [ ] Add a Rolemaster-only pre-roll modal on the play screen with autofocus, Escape dismissal, Enter submit, outside-click dismissal, and inline validation for signed integer input. -- [ ] Update Rolemaster roll execution and breakdown formatting so the situational modifier is shown explicitly and participates in retry-band evaluation and retry attempts. -- [ ] Add service, API, and Playwright coverage for the new behavior; update `README.md`; run `jb cleanupcode --build=False ...`; run `pwsh ./scripts/ci-local.ps1`; commit the iteration. +- [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. +- [ ] Add service, API, and Playwright coverage for the new behavior; update `README.md`; run `jb cleanupcode --build=False ...`; run `pwsh ./scripts/ci-local.ps1`; commit the iteration (completed: service and API coverage plus `README.md`; remaining: Playwright coverage, cleanup, full CI, commit). ## Surprises & Discoveries - 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: the current skill-roll request only carries visibility, so there is no existing place to send a one-shot situational modifier from the client to the server. - Evidence: `RpgRoller/Contracts/ApiContracts.cs` currently defines `public sealed record RollSkillRequest(string Visibility);`. +- Observation: the 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 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`. @@ -32,6 +32,9 @@ The important user-visible rule is that this temporary modifier must be applied - 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`. + ## Decision Log - Decision: the situational modifier will be transient request data only and will not be stored on `Skill`, `RollLogEntry`, or in a migration. @@ -60,7 +63,7 @@ The important user-visible rule is that this temporary modifier must be applied ## Outcomes & Retrospective -No implementation has started yet. The current outcome is a concrete, repository-specific execution plan that resolves the major design choices up front: the modifier is transient, Rolemaster-only, included in retry-band evaluation, and exposed through a dedicated modal in the workspace play flow. The feature will be complete when a novice can follow this plan, roll a Rolemaster skill with `+20`, and observe the same `+20` in both the first attempt and the retry attempt breakdown. +The first implementation slice is complete on the backend. Rolemaster skill-roll requests can now carry a transient situational modifier through the API and service pipeline, Rolemaster breakdowns show that modifier explicitly, and automatic retry math reuses the same modifier on both attempts. Service and API tests now prove standard-roll arithmetic, retry-trigger arithmetic, and the Rolemaster-only server guard. The browser play flow is still pending because the dedicated pre-roll modal has not been added yet. ## Context and Orientation @@ -188,3 +191,4 @@ In `RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor.cs` and `Chara 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. 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.