Add rolemaster situational roll modifier backend
This commit is contained in:
@@ -81,6 +81,7 @@ Rolemaster support:
|
|||||||
- Open-ended percentile expressions such as `d100!+85`
|
- Open-ended percentile expressions such as `d100!+85`
|
||||||
- Conditional `FumbleRange` handling for open-ended percentile skills and skill-group defaults
|
- 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
|
- 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`
|
- 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
|
- 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`
|
- Compact log badges and summaries for open-ended, retry, and fumble-related events, including `Retry +5` and `Retry +10`
|
||||||
|
|||||||
@@ -141,4 +141,45 @@ public sealed class RolemasterApiTests(WebApplicationFactory<Program> factory) :
|
|||||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -62,6 +62,24 @@ public sealed class ServiceRolemasterRollTests
|
|||||||
Assert.Equal(73, die.SignedContribution);
|
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]
|
[Fact]
|
||||||
public void RollSkill_RolemasterOpenEndedHigh_RecursesAndBuildsReadableLogSummary()
|
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<string[]>(logEntry.EventBadges));
|
||||||
|
Assert.All(roll.Dice, die => Assert.True(die.Attempt is 1 or 2));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RollSkill_RolemasterAutoRetryPlusTen_UsesRetryResultAndMarksAttempts()
|
public void RollSkill_RolemasterAutoRetryPlusTen_UsesRetryResultAndMarksAttempts()
|
||||||
{
|
{
|
||||||
@@ -256,4 +296,22 @@ public sealed class ServiceRolemasterRollTests
|
|||||||
Assert.Null(logEntry.EventBadges);
|
Assert.Null(logEntry.EventBadges);
|
||||||
Assert.All(roll.Dice, die => Assert.Null(die.Attempt));
|
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,11 @@ public sealed class ServiceRollHelperTests
|
|||||||
{
|
{
|
||||||
Assert.Equal("4+5+2=11", RollBreakdownFormatter.BuildBreakdown([4, 5], 2, 11));
|
Assert.Equal("4+5+2=11", RollBreakdownFormatter.BuildBreakdown([4, 5], 2, 11));
|
||||||
Assert.Equal("0=0", RollBreakdownFormatter.BuildBreakdown([], 0, 0));
|
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("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 = -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("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));
|
Assert.Equal("05", RollBreakdownFormatter.FormatRolemasterTriggerRoll(5));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ public sealed class WorkspaceQueryServiceTests
|
|||||||
throw new NotSupportedException();
|
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();
|
throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ internal static class SkillEndpoints
|
|||||||
|
|
||||||
group.MapPost("/skills/{skillId:guid}/roll", (Guid skillId, RollSkillRequest request, HttpContext context, IGameService game) =>
|
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);
|
return ApiResultMapper.ToApiResult(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 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);
|
public sealed record CustomRollRequest(string Expression, string Visibility);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace RpgRoller.Services;
|
|||||||
|
|
||||||
public sealed class GameRollService(GameStateStore stateStore, GamePersistenceService persistenceService, IDiceRoller diceRoller)
|
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)
|
lock (stateStore.Gate)
|
||||||
{
|
{
|
||||||
@@ -28,11 +28,17 @@ public sealed class GameRollService(GameStateStore stateStore, GamePersistenceSe
|
|||||||
if (!parsedExpression.Succeeded)
|
if (!parsedExpression.Succeeded)
|
||||||
return ServiceResult<RollResult>.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message);
|
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);
|
var parsedVisibility = RollVisibilityParser.Parse(visibility);
|
||||||
if (!parsedVisibility.Succeeded)
|
if (!parsedVisibility.Succeeded)
|
||||||
return ServiceResult<RollResult>.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message);
|
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, 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);
|
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 CampaignLogHistoryWindowSize = 100;
|
||||||
private const int CampaignLogLivePageSize = 25;
|
private const int CampaignLogLivePageSize = 25;
|
||||||
|
private const int MaxSituationalModifier = 1000;
|
||||||
private const string CustomRollBreakdownSeparator = " => ";
|
private const string CustomRollBreakdownSeparator = " => ";
|
||||||
private const string CustomRollLabel = "Custom roll";
|
private const string CustomRollLabel = "Custom roll";
|
||||||
private static readonly Guid CustomRollSkillId = Guid.Empty;
|
private static readonly Guid CustomRollSkillId = Guid.Empty;
|
||||||
|
|||||||
@@ -160,9 +160,9 @@ public sealed class GameService : IGameService
|
|||||||
return m_SkillService.GetCharacterSheet(sessionToken, characterId);
|
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)
|
public ServiceResult<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public interface IGameService
|
|||||||
ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId);
|
ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId);
|
||||||
ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId);
|
ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId);
|
||||||
|
|
||||||
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<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility);
|
||||||
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);
|
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);
|
||||||
ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null);
|
ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null);
|
||||||
|
|||||||
@@ -5,20 +5,20 @@ namespace RpgRoller.Services;
|
|||||||
|
|
||||||
public sealed class RolemasterRollEngine(IDiceRoller diceRoller)
|
public sealed class RolemasterRollEngine(IDiceRoller diceRoller)
|
||||||
{
|
{
|
||||||
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(DiceExpression expression, int? fumbleRange, bool rolemasterAutoRetry)
|
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(DiceExpression expression, int? fumbleRange, bool rolemasterAutoRetry, int situationalModifier = 0)
|
||||||
{
|
{
|
||||||
return expression.Kind switch
|
return expression.Kind switch
|
||||||
{
|
{
|
||||||
DiceExpressionKind.RolemasterOpenEndedPercentile => RollOpenEnded(expression, fumbleRange.GetValueOrDefault(), rolemasterAutoRetry),
|
DiceExpressionKind.RolemasterOpenEndedPercentile => RollOpenEnded(expression, fumbleRange.GetValueOrDefault(), rolemasterAutoRetry, situationalModifier),
|
||||||
_ => RollStandard(expression)
|
_ => RollStandard(expression, situationalModifier)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollStandard(DiceExpression expression, int? attempt = null)
|
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollStandard(DiceExpression expression, int situationalModifier, int? attempt = null)
|
||||||
{
|
{
|
||||||
var diceValues = new int[expression.DiceCount];
|
var diceValues = new int[expression.DiceCount];
|
||||||
var dice = new RollDieResult[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)
|
for (var i = 0; i < expression.DiceCount; i += 1)
|
||||||
{
|
{
|
||||||
var value = diceRoller.Roll(expression.Sides);
|
var value = diceRoller.Roll(expression.Sides);
|
||||||
@@ -27,17 +27,17 @@ public sealed class RolemasterRollEngine(IDiceRoller diceRoller)
|
|||||||
total += value;
|
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, bool rolemasterAutoRetry)
|
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> 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;
|
var retryBonus = rolemasterAutoRetry ? RolemasterRetryPolicy.ResolveAutoRetryBonus(firstAttempt.Total) : null;
|
||||||
if (!retryBonus.HasValue)
|
if (!retryBonus.HasValue)
|
||||||
return firstAttempt;
|
return firstAttempt;
|
||||||
|
|
||||||
var retryAttempt = RollOpenEndedAttempt(expression, fumbleRange, 2);
|
var retryAttempt = RollOpenEndedAttempt(expression, fumbleRange, situationalModifier, 2);
|
||||||
var finalTotal = retryAttempt.Total + retryBonus.Value;
|
var finalTotal = retryAttempt.Total + retryBonus.Value;
|
||||||
var breakdown = RollBreakdownFormatter.BuildRolemasterRetryBreakdown(firstAttempt.Breakdown, retryBonus.Value, retryAttempt.Breakdown, finalTotal);
|
var breakdown = RollBreakdownFormatter.BuildRolemasterRetryBreakdown(firstAttempt.Breakdown, retryBonus.Value, retryAttempt.Breakdown, finalTotal);
|
||||||
var dice = AddAttemptMarker(firstAttempt.Dice, 1).Concat(retryAttempt.Dice).ToArray();
|
var dice = AddAttemptMarker(firstAttempt.Dice, 1).Concat(retryAttempt.Dice).ToArray();
|
||||||
@@ -45,7 +45,7 @@ public sealed class RolemasterRollEngine(IDiceRoller diceRoller)
|
|||||||
return (finalTotal, breakdown, dice);
|
return (finalTotal, breakdown, dice);
|
||||||
}
|
}
|
||||||
|
|
||||||
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollOpenEndedAttempt(DiceExpression expression, int fumbleRange, int? attempt = null)
|
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 initialRoll = diceRoller.Roll(expression.Sides);
|
||||||
var followUpRolls = new List<int>();
|
var followUpRolls = new List<int>();
|
||||||
@@ -66,8 +66,8 @@ public sealed class RolemasterRollEngine(IDiceRoller diceRoller)
|
|||||||
baseTotal -= followUpRolls.Sum();
|
baseTotal -= followUpRolls.Sum();
|
||||||
}
|
}
|
||||||
|
|
||||||
var total = baseTotal + expression.Modifier;
|
var total = baseTotal + expression.Modifier + situationalModifier;
|
||||||
var breakdown = RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(initialRoll, followUpRolls, subtractFollowUps, expression.Modifier, total);
|
var breakdown = RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(initialRoll, followUpRolls, subtractFollowUps, expression.Modifier, total, situationalModifier);
|
||||||
return (total, breakdown, dice);
|
return (total, breakdown, dice);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,15 +12,17 @@ public static class RollBreakdownFormatter
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static string BuildRolemasterOpenEndedBreakdown(int initialRoll, IReadOnlyList<int> followUpRolls, bool subtractFollowUps, int modifier, int total)
|
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)
|
if (subtractFollowUps)
|
||||||
{
|
{
|
||||||
var segments = new List<string> { $"({FormatRolemasterTriggerRoll(initialRoll)})" };
|
var segments = new List<string> { $"({FormatRolemasterTriggerRoll(initialRoll)})" };
|
||||||
segments.AddRange(followUpRolls.Select(roll => $"-{roll}"));
|
segments.AddRange(followUpRolls.Select(roll => $"-{roll}"));
|
||||||
if (modifier > 0)
|
AddRolemasterModifierSegments(segments, modifier, situationalModifier);
|
||||||
segments.Add($"+{modifier}");
|
|
||||||
else if (modifier < 0)
|
|
||||||
segments.Add(modifier.ToString());
|
|
||||||
|
|
||||||
return $"{string.Join(" ", segments)} = {total}";
|
return $"{string.Join(" ", segments)} = {total}";
|
||||||
}
|
}
|
||||||
@@ -32,7 +34,7 @@ public static class RollBreakdownFormatter
|
|||||||
core = $"{core}+{followUpBreakdown}";
|
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)
|
public static string BuildRolemasterRetryBreakdown(string firstAttemptBreakdown, int retryBonus, string retryAttemptBreakdown, int finalTotal)
|
||||||
@@ -54,4 +56,40 @@ public static class RollBreakdownFormatter
|
|||||||
_ => $"{core}={total}"
|
_ => $"{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 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, bool rolemasterAutoRetry = false)
|
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)
|
if (ruleset == RulesetKind.D6)
|
||||||
return d6RollEngine.Roll(expression, wildDice, allowFumble);
|
return d6RollEngine.Roll(expression, wildDice, allowFumble);
|
||||||
|
|
||||||
if (ruleset == RulesetKind.Rolemaster)
|
if (ruleset == RulesetKind.Rolemaster)
|
||||||
return rolemasterRollEngine.Roll(expression, fumbleRange, rolemasterAutoRetry);
|
return rolemasterRollEngine.Roll(expression, fumbleRange, rolemasterAutoRetry, situationalModifier);
|
||||||
|
|
||||||
return standardRollEngine.Roll(expression);
|
return standardRollEngine.Roll(expression);
|
||||||
}
|
}
|
||||||
|
|||||||
16
TASKS.md
16
TASKS.md
@@ -13,18 +13,18 @@ The important user-visible rule is that this temporary modifier must be applied
|
|||||||
## Progress
|
## 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.
|
- [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.
|
- [ ] 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.
|
- [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.
|
- [ ] 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
|
## Surprises & Discoveries
|
||||||
|
|
||||||
- Observation: `TASKS.md` was empty before this plan was written, so this ExecPlan now defines the full intended work from scratch.
|
- 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`.
|
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.
|
- 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: `RpgRoller/Contracts/ApiContracts.cs` currently defines `public sealed record RollSkillRequest(string Visibility);`.
|
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.
|
- 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`.
|
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.
|
- 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`.
|
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 Log
|
||||||
|
|
||||||
- Decision: the situational modifier will be transient request data only and will not be stored on `Skill`, `RollLogEntry`, or in a migration.
|
- 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
|
## 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
|
## 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.
|
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): 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user