Implement Rolemaster roll execution
This commit is contained in:
@@ -64,6 +64,8 @@ Gameplay capabilities now include:
|
|||||||
- Admin user management is integrated into workspace screen toggles (`Play`, `Campaign Management`, `Admin`)
|
- 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 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 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
|
- 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
|
## Prerequisites
|
||||||
|
|||||||
88
RpgRoller.Tests/Api/RolemasterApiTests.cs
Normal file
88
RpgRoller.Tests/Api/RolemasterApiTests.cs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
namespace RpgRoller.Tests;
|
||||||
|
|
||||||
|
public sealed class RolemasterApiTests : ApiTestBase
|
||||||
|
{
|
||||||
|
public RolemasterApiTests(WebApplicationFactory<Program> factory) : base(factory)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RolemasterRollEndpoints_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<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Rolemaster", "rolemaster"));
|
||||||
|
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(client, "/api/characters", new("Hero", campaign.Id));
|
||||||
|
var initiative = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Initiative", "2d10+48", 0, false));
|
||||||
|
var perception = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Perception", "d100-15", 0, false));
|
||||||
|
|
||||||
|
var initiativeRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{initiative.Id}/roll", new("public"));
|
||||||
|
var percentileRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{perception.Id}/roll", new("public"));
|
||||||
|
var logPage = await GetAsync<CampaignLogPage>(client, $"/api/campaigns/{campaign.Id}/log/page?limit=10");
|
||||||
|
|
||||||
|
Assert.Equal(62, initiativeRoll.Result);
|
||||||
|
Assert.Equal("8+6+48=62", initiativeRoll.Breakdown);
|
||||||
|
Assert.All(initiativeRoll.Dice, die => Assert.Equal(RollDieKinds.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<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Rolemaster Open", "rolemaster"));
|
||||||
|
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(client, "/api/characters", new("Hero", campaign.Id));
|
||||||
|
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Awareness", "d100!+85", 0, false, null, 5));
|
||||||
|
|
||||||
|
var roll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{skill.Id}/roll", new("public"));
|
||||||
|
var logPage = await GetAsync<CampaignLogPage>(client, $"/api/campaigns/{campaign.Id}/log/page?limit=5");
|
||||||
|
var detail = await GetAsync<CampaignRollDetail>(client, $"/api/rolls/{roll.RollId}");
|
||||||
|
|
||||||
|
Assert.Equal(-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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
164
RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs
Normal file
164
RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
namespace RpgRoller.Tests;
|
||||||
|
|
||||||
|
public sealed class ServiceRolemasterRollTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void RollSkill_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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="roll-dice-strip" aria-label="@AriaLabel">
|
<div class="roll-dice-strip" aria-label="@AriaLabel">
|
||||||
@foreach (var die in Dice)
|
@foreach (var die in Dice)
|
||||||
{
|
{
|
||||||
<span class="@RollDieCssClass(die)" title="@RollDieTitle(die)">@RollDieGlyph(die.Roll)</span>
|
<span class="@RollDieCssClass(die)" title="@RollDieTitle(die)">@RollDieDisplay(die)</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
private static string RollDieCssClass(RollDieResult die)
|
||||||
{
|
{
|
||||||
var classes = new List<string> { "die-chip" };
|
var classes = new List<string> { "die-chip" };
|
||||||
@@ -39,12 +49,34 @@ public partial class RollDiceStrip
|
|||||||
if (die.Added)
|
if (die.Added)
|
||||||
classes.Add("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);
|
return string.Join(" ", classes);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string RollDieTitle(RollDieResult die)
|
private static string RollDieTitle(RollDieResult die)
|
||||||
{
|
{
|
||||||
var labels = new List<string> { $"Roll {die.Roll}" };
|
var labels = new List<string> { $"Roll {die.Roll}" };
|
||||||
|
if (die.Sequence.HasValue)
|
||||||
|
labels.Add($"step {die.Sequence.Value}");
|
||||||
|
|
||||||
if (die.Wild)
|
if (die.Wild)
|
||||||
labels.Add("wild");
|
labels.Add("wild");
|
||||||
|
|
||||||
@@ -60,6 +92,25 @@ public partial class RollDiceStrip
|
|||||||
if (die.Added)
|
if (die.Added)
|
||||||
labels.Add("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);
|
return string.Join(", ", labels);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace RpgRoller.Contracts;
|
namespace RpgRoller.Contracts;
|
||||||
|
|
||||||
public sealed record HealthResponse(string Status);
|
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 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<RollDieResult> Dice, DateTimeOffset TimestampUtc);
|
public sealed record RollResult(Guid RollId, Guid CampaignId, Guid CharacterId, Guid SkillId, Guid RollerUserId, string Visibility, int Result, string Breakdown, IReadOnlyList<RollDieResult> Dice, DateTimeOffset TimestampUtc);
|
||||||
|
|
||||||
|
|||||||
@@ -942,7 +942,21 @@ public sealed class GameService : IGameService
|
|||||||
|
|
||||||
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRoll(RulesetKind ruleset, DiceExpression expression, Skill skill)
|
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRoll(RulesetKind ruleset, DiceExpression expression, Skill skill)
|
||||||
{
|
{
|
||||||
return ruleset == RulesetKind.D6 ? ComputeD6Roll(expression, skill.WildDice, skill.AllowFumble) : ComputeStandardRoll(expression);
|
if (ruleset == RulesetKind.D6)
|
||||||
|
return ComputeD6Roll(expression, skill.WildDice, skill.AllowFumble);
|
||||||
|
|
||||||
|
if (ruleset == RulesetKind.Rolemaster)
|
||||||
|
{
|
||||||
|
return expression.Kind switch
|
||||||
|
{
|
||||||
|
DiceExpressionKind.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<RollDieResult> Dice) ComputeStandardRoll(DiceExpression expression)
|
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeStandardRoll(DiceExpression expression)
|
||||||
@@ -961,6 +975,62 @@ public sealed class GameService : IGameService
|
|||||||
return (total, BuildBreakdown(diceValues, expression.Modifier, total), dice);
|
return (total, BuildBreakdown(diceValues, expression.Modifier, total), dice);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> 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<RollDieResult> 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<RollDieResult> Dice) ComputeRolemasterOpenEndedRoll(DiceExpression expression, int fumbleRange)
|
||||||
|
{
|
||||||
|
var initialRoll = m_DiceRoller.Roll(expression.Sides);
|
||||||
|
var followUpRolls = new List<int>();
|
||||||
|
var dice = new List<RollDieResult>
|
||||||
|
{
|
||||||
|
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<RollDieResult> Dice) ComputeD6Roll(DiceExpression expression, int wildDice, bool allowFumble)
|
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeD6Roll(DiceExpression expression, int wildDice, bool allowFumble)
|
||||||
{
|
{
|
||||||
var initialDice = expression.DiceCount;
|
var initialDice = expression.DiceCount;
|
||||||
@@ -1041,14 +1111,63 @@ public sealed class GameService : IGameService
|
|||||||
return (total, BuildBreakdown(includedDice, expression.Modifier, total), dieResults);
|
return (total, BuildBreakdown(includedDice, expression.Modifier, total), dieResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private IEnumerable<int> RollRolemasterHighOpenEndedChain(List<RollDieResult> dice, int sequenceStart, bool subtract)
|
||||||
|
{
|
||||||
|
var followUpRolls = new List<int>();
|
||||||
|
var sequence = sequenceStart;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var roll = m_DiceRoller.Roll(100);
|
||||||
|
followUpRolls.Add(roll);
|
||||||
|
dice.Add(CreateRolemasterDie(
|
||||||
|
roll,
|
||||||
|
sequence,
|
||||||
|
subtract ? RollDieKinds.RolemasterOpenEndedLowSubtract : RollDieKinds.RolemasterOpenEndedHigh,
|
||||||
|
subtract ? -roll : roll));
|
||||||
|
|
||||||
|
sequence += 1;
|
||||||
|
if (roll < 96)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return followUpRolls;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RollDieResult CreateRolemasterDie(int roll, int sequence, string kind, int signedContribution)
|
||||||
|
{
|
||||||
|
return new(roll, false, false, false, false, kind == RollDieKinds.RolemasterOpenEndedHigh, sequence, kind, signedContribution);
|
||||||
|
}
|
||||||
|
|
||||||
private static string BuildBreakdown(IEnumerable<int> diceValues, int modifier, int total)
|
private static string BuildBreakdown(IEnumerable<int> diceValues, int modifier, int total)
|
||||||
{
|
{
|
||||||
var dicePart = string.Join("+", diceValues);
|
var dicePart = string.Join("+", diceValues);
|
||||||
if (string.IsNullOrWhiteSpace(dicePart))
|
if (string.IsNullOrWhiteSpace(dicePart))
|
||||||
dicePart = "0";
|
dicePart = "0";
|
||||||
|
|
||||||
var modifierPart = modifier > 0 ? $"+{modifier}" : string.Empty;
|
return BuildModifierBreakdown(dicePart, modifier, total);
|
||||||
return $"{dicePart}{modifierPart}={total}";
|
}
|
||||||
|
|
||||||
|
private static string BuildRolemasterOpenEndedBreakdown(int initialRoll, IReadOnlyList<int> 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<Guid?> ResolveSkillGroupForSkillChangeLocked(Guid? requestedSkillGroupId, Guid characterId)
|
private ServiceResult<Guid?> ResolveSkillGroupForSkillChangeLocked(Guid? requestedSkillGroupId, Guid characterId)
|
||||||
@@ -1248,6 +1367,9 @@ public sealed class GameService : IGameService
|
|||||||
if (dice.Count == 0)
|
if (dice.Count == 0)
|
||||||
return "No detail available.";
|
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()));
|
var preview = string.Join(", ", dice.Take(3).Select(die => die.Roll.ToString()));
|
||||||
if (dice.Count > 3)
|
if (dice.Count > 3)
|
||||||
preview = $"{preview}, ...";
|
preview = $"{preview}, ...";
|
||||||
@@ -1265,6 +1387,46 @@ public sealed class GameService : IGameService
|
|||||||
return tags.Count == 0 ? preview : $"{preview} | {string.Join(", ", tags)}";
|
return tags.Count == 0 ? preview : $"{preview} | {string.Join(", ", tags)}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string BuildRolemasterCompactLogSummary(IReadOnlyList<RollDieResult> dice)
|
||||||
|
{
|
||||||
|
var openEndedInitial = dice.FirstOrDefault(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal));
|
||||||
|
if (openEndedInitial is not null)
|
||||||
|
{
|
||||||
|
var highFollowUps = dice
|
||||||
|
.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedHigh, StringComparison.Ordinal))
|
||||||
|
.Select(die => die.Roll.ToString())
|
||||||
|
.ToArray();
|
||||||
|
if (highFollowUps.Length > 0)
|
||||||
|
return $"{openEndedInitial.Roll} + {string.Join(" + ", highFollowUps)} | open-ended high";
|
||||||
|
|
||||||
|
var lowFollowUps = dice
|
||||||
|
.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedLowSubtract, StringComparison.Ordinal))
|
||||||
|
.Select(die => die.Roll.ToString())
|
||||||
|
.ToArray();
|
||||||
|
if (lowFollowUps.Length > 0)
|
||||||
|
return $"{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)
|
private bool CanViewRollLocked(UserAccount user, Campaign campaign, RollLogEntry entry)
|
||||||
{
|
{
|
||||||
return CanViewCampaignLocked(user.Id, campaign.Id) &&
|
return CanViewCampaignLocked(user.Id, campaign.Id) &&
|
||||||
|
|||||||
@@ -528,15 +528,16 @@ select:focus-visible {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 2.1rem;
|
min-width: 2.1rem;
|
||||||
height: 2.1rem;
|
height: 2.1rem;
|
||||||
padding-top: 4px;
|
padding: 0.2rem 0.45rem 0;
|
||||||
border: 2px solid #2a2418;
|
border: 2px solid #2a2418;
|
||||||
border-radius: 0.45rem;
|
border-radius: 0.45rem;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
color: #1f1a13;
|
color: #1f1a13;
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.die-chip.wild {
|
.die-chip.wild {
|
||||||
@@ -565,6 +566,36 @@ select:focus-visible {
|
|||||||
border-style: dashed;
|
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,
|
.empty,
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|||||||
@@ -1029,6 +1029,20 @@
|
|||||||
},
|
},
|
||||||
"added": {
|
"added": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"sequence": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"signedContribution": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"nullable": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
const { test, expect } = require("@playwright/test");
|
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 }) => {
|
test("home page loads auth entry points", async ({ page }) => {
|
||||||
await page.goto("/");
|
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("Username").first()).toBeVisible();
|
||||||
await expect(page.getByLabel("Password").nth(1)).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();
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user