diff --git a/README.md b/README.md index 8f4e35c..d33ceed 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ Gameplay capabilities now include: - Admin user management is integrated into workspace screen toggles (`Play`, `Campaign Management`, `Admin`) - Rolemaster expression validation currently recognizes `2d10+48`, `d100+4`, and `d100!+85`, including Rolemaster-only negative modifiers such as `d100-15` - Rolemaster open-ended percentile skills and skill-group defaults now persist a nullable `FumbleRange` field, while D6 and D&D rows migrate forward unchanged +- Rolemaster roll execution now supports initiative (`2d10+x`), standard percentile (`d100+x`), and open-ended percentile (`d100!+x`) with recursive high-end chaining and low-end subtraction based on `FumbleRange` +- Compact campaign-log summaries stay dense for Rolemaster rolls, while lazy-loaded roll detail includes ordered die metadata for each open-ended follow-up step - Startup migration coverage is validated against a copied temp-file instance of `RpgRoller/App_Data/rpgroller.development.db` before feature work is considered complete ## Prerequisites diff --git a/RpgRoller.Tests/Api/RolemasterApiTests.cs b/RpgRoller.Tests/Api/RolemasterApiTests.cs new file mode 100644 index 0000000..bf0918e --- /dev/null +++ b/RpgRoller.Tests/Api/RolemasterApiTests.cs @@ -0,0 +1,88 @@ +namespace RpgRoller.Tests; + +public sealed class RolemasterApiTests : ApiTestBase +{ + public RolemasterApiTests(WebApplicationFactory factory) : base(factory) + { + } + + [Fact] + public async Task RolemasterRollEndpoints_ExecuteInitiativeAndPercentile() + { + using var factory = CreateFactory(8, 6, 74); + using var client = factory.CreateClient(new() { AllowAutoRedirect = false }); + + await RegisterAsync(client, "rolemaster-api", "Password123", "Rolemaster Api"); + await LoginAsync(client, "rolemaster-api", "Password123"); + + var campaign = await PostAsync(client, "/api/campaigns", new("Rolemaster", "rolemaster")); + var character = await PostAsync(client, "/api/characters", new("Hero", campaign.Id)); + var initiative = await PostAsync(client, $"/api/characters/{character.Id}/skills", new("Initiative", "2d10+48", 0, false)); + var perception = await PostAsync(client, $"/api/characters/{character.Id}/skills", new("Perception", "d100-15", 0, false)); + + var initiativeRoll = await PostAsync(client, $"/api/skills/{initiative.Id}/roll", new("public")); + var percentileRoll = await PostAsync(client, $"/api/skills/{perception.Id}/roll", new("public")); + var logPage = await GetAsync(client, $"/api/campaigns/{campaign.Id}/log/page?limit=10"); + + Assert.Equal(62, initiativeRoll.Result); + Assert.Equal("8+6+48=62", initiativeRoll.Breakdown); + Assert.All(initiativeRoll.Dice, die => Assert.Equal(RollDieKinds.RolemasterInitiative, die.Kind)); + + Assert.Equal(59, percentileRoll.Result); + Assert.Equal("74-15=59", percentileRoll.Breakdown); + Assert.Equal(RollDieKinds.RolemasterPercentile, Assert.Single(percentileRoll.Dice).Kind); + + Assert.Equal(2, logPage.Entries.Length); + Assert.Equal("8 + 6 | initiative", logPage.Entries[0].SummaryText); + Assert.Equal("74 | percentile", logPage.Entries[1].SummaryText); + } + + [Fact] + public async Task RolemasterOpenEndedRolls_AppearInLogPageAndDetail() + { + using var factory = CreateFactory(5, 97, 100, 12); + using var client = factory.CreateClient(new() { AllowAutoRedirect = false }); + + await RegisterAsync(client, "rolemaster-open-api", "Password123", "Rolemaster Open Api"); + await LoginAsync(client, "rolemaster-open-api", "Password123"); + + var campaign = await PostAsync(client, "/api/campaigns", new("Rolemaster Open", "rolemaster")); + var character = await PostAsync(client, "/api/characters", new("Hero", campaign.Id)); + var skill = await PostAsync(client, $"/api/characters/{character.Id}/skills", new("Awareness", "d100!+85", 0, false, null, 5)); + + var roll = await PostAsync(client, $"/api/skills/{skill.Id}/roll", new("public")); + var logPage = await GetAsync(client, $"/api/campaigns/{campaign.Id}/log/page?limit=5"); + var detail = await GetAsync(client, $"/api/rolls/{roll.RollId}"); + + Assert.Equal(-119, roll.Result); + Assert.Equal("5-(97+100+12)+85=-119", roll.Breakdown); + Assert.Equal("5 - (97 + 100 + 12) | open-ended low", Assert.Single(logPage.Entries).SummaryText); + Assert.Equal(roll.Breakdown, detail.Breakdown); + Assert.Collection( + detail.Dice, + die => + { + Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind); + Assert.Equal(1, die.Sequence); + Assert.Equal(5, die.SignedContribution); + }, + die => + { + Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind); + Assert.Equal(2, die.Sequence); + Assert.Equal(-97, die.SignedContribution); + }, + die => + { + Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind); + Assert.Equal(3, die.Sequence); + Assert.Equal(-100, die.SignedContribution); + }, + die => + { + Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind); + Assert.Equal(4, die.Sequence); + Assert.Equal(-12, die.SignedContribution); + }); + } +} diff --git a/RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs b/RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs new file mode 100644 index 0000000..8256f91 --- /dev/null +++ b/RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs @@ -0,0 +1,164 @@ +namespace RpgRoller.Tests; + +public sealed class ServiceRolemasterRollTests +{ + [Fact] + public void RollSkill_RolemasterInitiative_ComputesTotalAndTagsDice() + { + using var harness = ServiceTestSupport.CreateHarness(7, 10); + var service = harness.Service; + + service.Register("gm-init", "Password123", "GM"); + var session = ServiceTestSupport.GetValue(service.Login("gm-init", "Password123")).SessionToken; + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster", "rolemaster")); + var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id)); + var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Initiative", "2d10+48", 0, false)); + + var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public")); + var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)); + + Assert.Equal(65, roll.Result); + Assert.Equal("7+10+48=65", roll.Breakdown); + Assert.Equal("7 + 10 | initiative", Assert.Single(logPage.Entries).SummaryText); + Assert.Collection( + roll.Dice, + die => + { + Assert.Equal(7, die.Roll); + Assert.Equal(1, die.Sequence); + Assert.Equal(RollDieKinds.RolemasterInitiative, die.Kind); + Assert.Equal(7, die.SignedContribution); + }, + die => + { + Assert.Equal(10, die.Roll); + Assert.Equal(2, die.Sequence); + Assert.Equal(RollDieKinds.RolemasterInitiative, die.Kind); + Assert.Equal(10, die.SignedContribution); + }); + } + + [Fact] + public void RollSkill_RolemasterPercentile_ComputesTotalAndTagsDice() + { + using var harness = ServiceTestSupport.CreateHarness(73); + var service = harness.Service; + + service.Register("gm-percentile", "Password123", "GM"); + var session = ServiceTestSupport.GetValue(service.Login("gm-percentile", "Password123")).SessionToken; + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster", "rolemaster")); + var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id)); + var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Perception", "d100-15", 0, false)); + + var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public")); + var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)); + + Assert.Equal(58, roll.Result); + Assert.Equal("73-15=58", roll.Breakdown); + Assert.Equal("73 | percentile", Assert.Single(logPage.Entries).SummaryText); + + var die = Assert.Single(roll.Dice); + Assert.Equal(73, die.Roll); + Assert.Equal(1, die.Sequence); + Assert.Equal(RollDieKinds.RolemasterPercentile, die.Kind); + Assert.Equal(73, die.SignedContribution); + } + + [Fact] + public void RollSkill_RolemasterOpenEndedHigh_RecursesAndBuildsReadableLogSummary() + { + using var harness = ServiceTestSupport.CreateHarness(97, 96, 45); + var service = harness.Service; + + service.Register("gm-open-high", "Password123", "GM"); + var session = ServiceTestSupport.GetValue(service.Login("gm-open-high", "Password123")).SessionToken; + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster", "rolemaster")); + var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id)); + var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Awareness", "d100!+85", 0, false, null, 5)); + + var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public")); + var detail = ServiceTestSupport.GetValue(service.GetRollDetail(session, roll.RollId)); + var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)); + + Assert.Equal(323, roll.Result); + Assert.Equal("97+96+45+85=323", roll.Breakdown); + Assert.Equal("97 + 96 + 45 | open-ended high", Assert.Single(logPage.Entries).SummaryText); + Assert.Equal(roll.Breakdown, detail.Breakdown); + Assert.Collection( + detail.Dice, + die => + { + Assert.Equal(97, die.Roll); + Assert.Equal(1, die.Sequence); + Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind); + Assert.Equal(97, die.SignedContribution); + Assert.False(die.Added); + }, + die => + { + Assert.Equal(96, die.Roll); + Assert.Equal(2, die.Sequence); + Assert.Equal(RollDieKinds.RolemasterOpenEndedHigh, die.Kind); + Assert.Equal(96, die.SignedContribution); + Assert.True(die.Added); + }, + die => + { + Assert.Equal(45, die.Roll); + Assert.Equal(3, die.Sequence); + Assert.Equal(RollDieKinds.RolemasterOpenEndedHigh, die.Kind); + Assert.Equal(45, die.SignedContribution); + Assert.True(die.Added); + }); + } + + [Fact] + public void RollSkill_RolemasterOpenEndedLow_SubtractsRecursiveHighChain() + { + using var harness = ServiceTestSupport.CreateHarness(5, 97, 100, 12); + var service = harness.Service; + + service.Register("gm-open-low", "Password123", "GM"); + var session = ServiceTestSupport.GetValue(service.Login("gm-open-low", "Password123")).SessionToken; + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster", "rolemaster")); + var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id)); + var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Awareness", "d100!+85", 0, false, null, 5)); + + var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public")); + var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)); + + Assert.Equal(-119, roll.Result); + Assert.Equal("5-(97+100+12)+85=-119", roll.Breakdown); + Assert.Equal("5 - (97 + 100 + 12) | open-ended low", Assert.Single(logPage.Entries).SummaryText); + Assert.Collection( + roll.Dice, + die => + { + Assert.Equal(5, die.Roll); + Assert.Equal(1, die.Sequence); + Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind); + Assert.Equal(5, die.SignedContribution); + }, + die => + { + Assert.Equal(97, die.Roll); + Assert.Equal(2, die.Sequence); + Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind); + Assert.Equal(-97, die.SignedContribution); + }, + die => + { + Assert.Equal(100, die.Roll); + Assert.Equal(3, die.Sequence); + Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind); + Assert.Equal(-100, die.SignedContribution); + }, + die => + { + Assert.Equal(12, die.Roll); + Assert.Equal(4, die.Sequence); + Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind); + Assert.Equal(-12, die.SignedContribution); + }); + } +} diff --git a/RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor b/RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor index 3ee2e19..6e9977d 100644 --- a/RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor +++ b/RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor @@ -3,7 +3,7 @@
@foreach (var die in Dice) { - @RollDieGlyph(die.Roll) + @RollDieDisplay(die) }
} diff --git a/RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs b/RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs index eba82d5..5cc40ac 100644 --- a/RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs @@ -21,6 +21,16 @@ public partial class RollDiceStrip }; } + private static string RollDieDisplay(RollDieResult die) + { + return die.Kind switch + { + RollDieKinds.RolemasterOpenEndedHigh => $"+{die.Roll}", + RollDieKinds.RolemasterOpenEndedLowSubtract => $"-{die.Roll}", + _ => RollDieGlyph(die.Roll) + }; + } + private static string RollDieCssClass(RollDieResult die) { var classes = new List { "die-chip" }; @@ -39,12 +49,34 @@ public partial class RollDiceStrip if (die.Added) classes.Add("added"); + switch (die.Kind) + { + case RollDieKinds.RolemasterInitiative: + classes.Add("rolemaster-initiative"); + break; + case RollDieKinds.RolemasterPercentile: + classes.Add("rolemaster-percentile"); + break; + case RollDieKinds.RolemasterOpenEndedInitial: + classes.Add("rolemaster-open-ended-initial"); + break; + case RollDieKinds.RolemasterOpenEndedHigh: + classes.Add("rolemaster-open-ended-high"); + break; + case RollDieKinds.RolemasterOpenEndedLowSubtract: + classes.Add("rolemaster-open-ended-low-subtract"); + break; + } + return string.Join(" ", classes); } private static string RollDieTitle(RollDieResult die) { var labels = new List { $"Roll {die.Roll}" }; + if (die.Sequence.HasValue) + labels.Add($"step {die.Sequence.Value}"); + if (die.Wild) labels.Add("wild"); @@ -60,6 +92,25 @@ public partial class RollDiceStrip if (die.Added) labels.Add("added"); + switch (die.Kind) + { + case RollDieKinds.RolemasterInitiative: + labels.Add("Rolemaster initiative"); + break; + case RollDieKinds.RolemasterPercentile: + labels.Add("Rolemaster percentile"); + break; + case RollDieKinds.RolemasterOpenEndedInitial: + labels.Add("Rolemaster open-ended initial"); + break; + case RollDieKinds.RolemasterOpenEndedHigh: + labels.Add($"Rolemaster high follow-up (+{die.Roll})"); + break; + case RollDieKinds.RolemasterOpenEndedLowSubtract: + labels.Add($"Rolemaster low-end subtraction (-{die.Roll})"); + break; + } + return string.Join(", ", labels); } @@ -68,4 +119,4 @@ public partial class RollDiceStrip [Parameter] public string AriaLabel { get; set; } = "Rolled dice"; -} \ No newline at end of file +} diff --git a/RpgRoller/Contracts/ApiContracts.cs b/RpgRoller/Contracts/ApiContracts.cs index 9305f75..458603e 100644 --- a/RpgRoller/Contracts/ApiContracts.cs +++ b/RpgRoller/Contracts/ApiContracts.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace RpgRoller.Contracts; public sealed record HealthResponse(string Status); @@ -48,7 +50,50 @@ public sealed record SkillSummary(Guid Id, Guid CharacterId, Guid? SkillGroupId, public sealed record RollSkillRequest(string Visibility); -public sealed record RollDieResult(int Roll, bool Crit, bool Fumble, bool Wild, bool Removed, bool Added); +public static class RollDieKinds +{ + public const string RolemasterInitiative = "rolemaster-initiative"; + public const string RolemasterPercentile = "rolemaster-percentile"; + public const string RolemasterOpenEndedInitial = "rolemaster-open-ended-initial"; + public const string RolemasterOpenEndedHigh = "rolemaster-open-ended-high"; + public const string RolemasterOpenEndedLowSubtract = "rolemaster-open-ended-low-subtract"; +} + +public sealed record RollDieResult +{ + public RollDieResult() + { + } + + public RollDieResult(int roll, bool crit, bool fumble, bool wild, bool removed, bool added, int? sequence = null, string? kind = null, int? signedContribution = null) + { + Roll = roll; + Crit = crit; + Fumble = fumble; + Wild = wild; + Removed = removed; + Added = added; + Sequence = sequence; + Kind = kind; + SignedContribution = signedContribution; + } + + public int Roll { get; init; } + public bool Crit { get; init; } + public bool Fumble { get; init; } + public bool Wild { get; init; } + public bool Removed { get; init; } + public bool Added { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Sequence { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Kind { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? SignedContribution { get; init; } +} public sealed record RollResult(Guid RollId, Guid CampaignId, Guid CharacterId, Guid SkillId, Guid RollerUserId, string Visibility, int Result, string Breakdown, IReadOnlyList Dice, DateTimeOffset TimestampUtc); diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index 68d4430..ee773e5 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -942,7 +942,21 @@ public sealed class GameService : IGameService private (int Total, string Breakdown, IReadOnlyList Dice) ComputeRoll(RulesetKind ruleset, DiceExpression expression, Skill skill) { - return ruleset == RulesetKind.D6 ? ComputeD6Roll(expression, skill.WildDice, skill.AllowFumble) : ComputeStandardRoll(expression); + if (ruleset == RulesetKind.D6) + return ComputeD6Roll(expression, skill.WildDice, skill.AllowFumble); + + if (ruleset == RulesetKind.Rolemaster) + { + return expression.Kind switch + { + DiceExpressionKind.RolemasterInitiative => ComputeRolemasterInitiativeRoll(expression), + DiceExpressionKind.RolemasterPercentile => ComputeRolemasterPercentileRoll(expression), + DiceExpressionKind.RolemasterOpenEndedPercentile => ComputeRolemasterOpenEndedRoll(expression, skill.FumbleRange.GetValueOrDefault()), + _ => ComputeStandardRoll(expression) + }; + } + + return ComputeStandardRoll(expression); } private (int Total, string Breakdown, IReadOnlyList Dice) ComputeStandardRoll(DiceExpression expression) @@ -961,6 +975,62 @@ public sealed class GameService : IGameService return (total, BuildBreakdown(diceValues, expression.Modifier, total), dice); } + private (int Total, string Breakdown, IReadOnlyList Dice) ComputeRolemasterInitiativeRoll(DiceExpression expression) + { + var diceValues = new int[expression.DiceCount]; + var dice = new RollDieResult[expression.DiceCount]; + var total = expression.Modifier; + for (var i = 0; i < expression.DiceCount; i += 1) + { + var value = m_DiceRoller.Roll(expression.Sides); + diceValues[i] = value; + dice[i] = CreateRolemasterDie(value, i + 1, RollDieKinds.RolemasterInitiative, value); + total += value; + } + + return (total, BuildBreakdown(diceValues, expression.Modifier, total), dice); + } + + private (int Total, string Breakdown, IReadOnlyList Dice) ComputeRolemasterPercentileRoll(DiceExpression expression) + { + var roll = m_DiceRoller.Roll(expression.Sides); + var total = roll + expression.Modifier; + var dice = new[] + { + CreateRolemasterDie(roll, 1, RollDieKinds.RolemasterPercentile, roll) + }; + + return (total, BuildBreakdown([roll], expression.Modifier, total), dice); + } + + private (int Total, string Breakdown, IReadOnlyList Dice) ComputeRolemasterOpenEndedRoll(DiceExpression expression, int fumbleRange) + { + var initialRoll = m_DiceRoller.Roll(expression.Sides); + var followUpRolls = new List(); + var dice = new List + { + CreateRolemasterDie(initialRoll, 1, RollDieKinds.RolemasterOpenEndedInitial, initialRoll) + }; + + var baseTotal = initialRoll; + var subtractFollowUps = false; + if (initialRoll >= 96) + { + followUpRolls.AddRange(RollRolemasterHighOpenEndedChain(dice, sequenceStart: 2, subtract: false)); + baseTotal += followUpRolls.Sum(); + } + else if (initialRoll <= fumbleRange) + { + subtractFollowUps = true; + followUpRolls.AddRange(RollRolemasterHighOpenEndedChain(dice, sequenceStart: 2, subtract: true)); + baseTotal -= followUpRolls.Sum(); + } + + var total = baseTotal + expression.Modifier; + var breakdown = BuildRolemasterOpenEndedBreakdown(initialRoll, followUpRolls, subtractFollowUps, expression.Modifier, total); + return (total, breakdown, dice); + } + private (int Total, string Breakdown, IReadOnlyList Dice) ComputeD6Roll(DiceExpression expression, int wildDice, bool allowFumble) { var initialDice = expression.DiceCount; @@ -1041,14 +1111,63 @@ public sealed class GameService : IGameService return (total, BuildBreakdown(includedDice, expression.Modifier, total), dieResults); } + private IEnumerable RollRolemasterHighOpenEndedChain(List dice, int sequenceStart, bool subtract) + { + var followUpRolls = new List(); + var sequence = sequenceStart; + + while (true) + { + var roll = m_DiceRoller.Roll(100); + followUpRolls.Add(roll); + dice.Add(CreateRolemasterDie( + roll, + sequence, + subtract ? RollDieKinds.RolemasterOpenEndedLowSubtract : RollDieKinds.RolemasterOpenEndedHigh, + subtract ? -roll : roll)); + + sequence += 1; + if (roll < 96) + break; + } + + return followUpRolls; + } + + private static RollDieResult CreateRolemasterDie(int roll, int sequence, string kind, int signedContribution) + { + return new(roll, false, false, false, false, kind == RollDieKinds.RolemasterOpenEndedHigh, sequence, kind, signedContribution); + } + private static string BuildBreakdown(IEnumerable diceValues, int modifier, int total) { var dicePart = string.Join("+", diceValues); if (string.IsNullOrWhiteSpace(dicePart)) dicePart = "0"; - var modifierPart = modifier > 0 ? $"+{modifier}" : string.Empty; - return $"{dicePart}{modifierPart}={total}"; + return BuildModifierBreakdown(dicePart, modifier, total); + } + + private static string BuildRolemasterOpenEndedBreakdown(int initialRoll, IReadOnlyList followUpRolls, bool subtractFollowUps, int modifier, int total) + { + var core = initialRoll.ToString(); + if (followUpRolls.Count > 0) + { + var followUpBreakdown = string.Join("+", followUpRolls); + core = subtractFollowUps ? $"{core}-({followUpBreakdown})" : $"{core}+{followUpBreakdown}"; + } + + return BuildModifierBreakdown(core, modifier, total); + } + + private static string BuildModifierBreakdown(string core, int modifier, int total) + { + return modifier switch + { + > 0 => $"{core}+{modifier}={total}", + < 0 => $"{core}{modifier}={total}", + _ => $"{core}={total}" + }; } private ServiceResult ResolveSkillGroupForSkillChangeLocked(Guid? requestedSkillGroupId, Guid characterId) @@ -1248,6 +1367,9 @@ public sealed class GameService : IGameService if (dice.Count == 0) return "No detail available."; + if (dice.Any(die => IsRolemasterDieKind(die.Kind))) + return BuildRolemasterCompactLogSummary(dice); + var preview = string.Join(", ", dice.Take(3).Select(die => die.Roll.ToString())); if (dice.Count > 3) preview = $"{preview}, ..."; @@ -1265,6 +1387,46 @@ public sealed class GameService : IGameService return tags.Count == 0 ? preview : $"{preview} | {string.Join(", ", tags)}"; } + private static string BuildRolemasterCompactLogSummary(IReadOnlyList dice) + { + var openEndedInitial = dice.FirstOrDefault(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal)); + if (openEndedInitial is not null) + { + var highFollowUps = dice + .Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedHigh, StringComparison.Ordinal)) + .Select(die => die.Roll.ToString()) + .ToArray(); + if (highFollowUps.Length > 0) + return $"{openEndedInitial.Roll} + {string.Join(" + ", highFollowUps)} | open-ended high"; + + var lowFollowUps = dice + .Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedLowSubtract, StringComparison.Ordinal)) + .Select(die => die.Roll.ToString()) + .ToArray(); + if (lowFollowUps.Length > 0) + return $"{openEndedInitial.Roll} - ({string.Join(" + ", lowFollowUps)}) | open-ended low"; + + return $"{openEndedInitial.Roll} | open-ended"; + } + + if (dice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterInitiative, StringComparison.Ordinal))) + return $"{string.Join(" + ", dice.Select(die => die.Roll.ToString()))} | initiative"; + + if (dice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterPercentile, StringComparison.Ordinal))) + return $"{dice[0].Roll} | percentile"; + + return string.Join(", ", dice.Take(3).Select(die => die.Roll.ToString())); + } + + private static bool IsRolemasterDieKind(string? kind) + { + return kind is RollDieKinds.RolemasterInitiative or + RollDieKinds.RolemasterPercentile or + RollDieKinds.RolemasterOpenEndedInitial or + RollDieKinds.RolemasterOpenEndedHigh or + RollDieKinds.RolemasterOpenEndedLowSubtract; + } + private bool CanViewRollLocked(UserAccount user, Campaign campaign, RollLogEntry entry) { return CanViewCampaignLocked(user.Id, campaign.Id) && diff --git a/RpgRoller/wwwroot/styles.css b/RpgRoller/wwwroot/styles.css index 8f58ec9..afd7927 100644 --- a/RpgRoller/wwwroot/styles.css +++ b/RpgRoller/wwwroot/styles.css @@ -528,15 +528,16 @@ select:focus-visible { display: inline-flex; align-items: center; justify-content: center; - width: 2.1rem; + min-width: 2.1rem; height: 2.1rem; - padding-top: 4px; + padding: 0.2rem 0.45rem 0; border: 2px solid #2a2418; border-radius: 0.45rem; background: #ffffff; color: #1f1a13; font-size: 2rem; line-height: 1; + font-variant-numeric: tabular-nums; } .die-chip.wild { @@ -565,6 +566,36 @@ select:focus-visible { border-style: dashed; } +.die-chip.rolemaster-initiative, +.die-chip.rolemaster-percentile, +.die-chip.rolemaster-open-ended-initial, +.die-chip.rolemaster-open-ended-high, +.die-chip.rolemaster-open-ended-low-subtract { + padding-top: 0; + font-size: 1rem; + font-weight: 700; + line-height: 1.1; +} + +.die-chip.rolemaster-initiative, +.die-chip.rolemaster-percentile, +.die-chip.rolemaster-open-ended-initial { + background: #f8f1df; + color: #3f2f12; +} + +.die-chip.rolemaster-open-ended-high { + background: #dff6df; + color: #1d5b26; + border-color: #2a7c39; +} + +.die-chip.rolemaster-open-ended-low-subtract { + background: #ffe1dc; + color: #8a2217; + border-color: #b74334; +} + .empty, .muted { color: var(--muted); diff --git a/openapi/RpgRoller.json b/openapi/RpgRoller.json index f0ad162..56f8d2f 100644 --- a/openapi/RpgRoller.json +++ b/openapi/RpgRoller.json @@ -1029,6 +1029,20 @@ }, "added": { "type": "boolean" + }, + "sequence": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "kind": { + "type": "string", + "nullable": true + }, + "signedContribution": { + "type": "integer", + "format": "int32", + "nullable": true } }, "required": [ diff --git a/tests/e2e/smoke.spec.js b/tests/e2e/smoke.spec.js index 900c0af..7b92fd3 100644 --- a/tests/e2e/smoke.spec.js +++ b/tests/e2e/smoke.spec.js @@ -1,5 +1,11 @@ const { test, expect } = require("@playwright/test"); +async function postJson(request, url, data) { + const response = await request.post(url, { data }); + expect(response.ok()).toBeTruthy(); + return await response.json(); +} + test("home page loads auth entry points", async ({ page }) => { await page.goto("/"); @@ -9,3 +15,51 @@ test("home page loads auth entry points", async ({ page }) => { await expect(page.getByLabel("Username").first()).toBeVisible(); await expect(page.getByLabel("Password").nth(1)).toBeVisible(); }); + +test("Rolemaster open-ended roll detail renders specialized dice chips", async ({ page, context }) => { + const username = `rm-${Date.now()}`; + const displayName = "Rolemaster Smoke"; + + await postJson(context.request, "/api/auth/register", { + username, + password: "Password123", + displayName + }); + + const loginResponse = await context.request.post("/api/auth/login", { + data: { + username, + password: "Password123" + } + }); + expect(loginResponse.ok()).toBeTruthy(); + + const campaign = await postJson(context.request, "/api/campaigns", { + name: "Rolemaster Smoke", + rulesetId: "rolemaster" + }); + const character = await postJson(context.request, "/api/characters", { + name: "Open Ender", + campaignId: campaign.id + }); + const skill = await postJson(context.request, `/api/characters/${character.id}/skills`, { + name: "Open Sight", + diceRollDefinition: "d100!+85", + wildDice: 0, + allowFumble: false, + fumbleRange: 95 + }); + + await postJson(context.request, `/api/skills/${skill.id}/roll`, { visibility: "public" }); + + await page.goto("/"); + await expect(page.getByText("Campaign Log")).toBeVisible(); + + const logEntry = page.locator(".log-panel .log-entry-toggle").first(); + await expect(logEntry).toBeVisible(); + await logEntry.click(); + + const rolemasterFollowUpDice = page.locator(".die-chip.rolemaster-open-ended-high, .die-chip.rolemaster-open-ended-low-subtract"); + await expect(rolemasterFollowUpDice.first()).toBeVisible(); + await expect(page.locator(".log-detail .roll-dice-strip")).toBeVisible(); +});