Implement Rolemaster roll execution

This commit is contained in:
2026-04-03 00:51:36 +02:00
parent 9b9927084b
commit 0059fde74f
10 changed files with 619 additions and 8 deletions

View File

@@ -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

View 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);
});
}
}

View 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);
});
}
}

View File

@@ -3,7 +3,7 @@
<div class="roll-dice-strip" aria-label="@AriaLabel">
@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>
}

View File

@@ -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<string> { "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<string> { $"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);
}

View File

@@ -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<RollDieResult> Dice, DateTimeOffset TimestampUtc);

View File

@@ -942,7 +942,21 @@ public sealed class GameService : IGameService
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)
@@ -961,6 +975,62 @@ public sealed class GameService : IGameService
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)
{
var initialDice = expression.DiceCount;
@@ -1041,14 +1111,63 @@ public sealed class GameService : IGameService
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)
{
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<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)
@@ -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<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)
{
return CanViewCampaignLocked(user.Id, campaign.Id) &&

View File

@@ -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);

View File

@@ -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": [

View File

@@ -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();
});