Implement d6 wild dice/fumble skills and die-state rolls

This commit is contained in:
2026-02-26 08:26:12 +01:00
parent 0f44cc466b
commit 11ab7c959b
22 changed files with 560 additions and 50 deletions

View File

@@ -9,13 +9,13 @@ internal static class SkillEndpoints
{
group.MapPost("/characters/{characterId:guid}/skills", (Guid characterId, CreateSkillRequest request, HttpContext context, IGameService game) =>
{
var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition);
var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble);
return ApiResultMapper.ToApiResult(result);
});
group.MapPut("/skills/{skillId:guid}", (Guid skillId, UpdateSkillRequest request, HttpContext context, IGameService game) =>
{
var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.Name, request.DiceRollDefinition);
var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble);
return ApiResultMapper.ToApiResult(result);
});

View File

@@ -182,7 +182,7 @@
{
var isSelectedSkill = SelectedSkillId == skill.Id;
<button type="button" class="skill-item @(isSelectedSkill ? "active" : string.Empty)" @onclick="() => SelectSkill(skill.Id)">
<strong>@skill.Name</strong><span>@skill.DiceRollDefinition</span>
<strong>@skill.Name</strong><span>@SkillDefinitionLabel(skill)</span>
</button>
}
</div>
@@ -207,6 +207,15 @@
else
{
<p class="roll-total">@LastRoll.Result</p>
@if (LastRoll.Dice.Count > 0)
{
<div class="roll-dice-strip" aria-label="Rolled dice">
@foreach (var die in LastRoll.Dice)
{
<span class="@RollDieCssClass(die)" title="@RollDieTitle(die)">@RollDieGlyph(die.Roll)</span>
}
</div>
}
<p>@LastRoll.Breakdown</p>
<p><span class="badge @(LastRoll.Visibility == "private" ? "private-self" : "public")">@LastRoll.Visibility</span> <time title="@LastRoll.TimestampUtc.ToString("O")">@LastRoll.TimestampUtc.ToLocalTime().ToString("g")</time></p>
}
@@ -430,6 +439,17 @@
{
<p class="field-error">@createSkillExpressionError</p>
}
@if (IsSelectedCampaignD6)
{
<label for="skill-create-wild-dice">Wild dice</label>
<input id="skill-create-wild-dice" type="number" min="1" step="1" @bind="SkillForm.WildDice" />
@if (SkillErrors.TryGetValue("wildDice", out var createSkillWildDiceError))
{
<p class="field-error">@createSkillWildDiceError</p>
}
<label for="skill-create-allow-fumble">Allow fumble</label>
<input id="skill-create-allow-fumble" type="checkbox" @bind="SkillForm.AllowFumble" />
}
<div class="inline-actions">
<button type="submit" disabled="@IsMutating">Create Skill</button>
<button type="button" class="ghost" @onclick="CloseSkillModals">Cancel</button>
@@ -461,6 +481,17 @@
{
<p class="field-error">@editSkillExpressionError</p>
}
@if (IsSelectedCampaignD6)
{
<label for="skill-edit-wild-dice">Wild dice</label>
<input id="skill-edit-wild-dice" type="number" min="1" step="1" @bind="EditSkillForm.WildDice" />
@if (EditSkillErrors.TryGetValue("wildDice", out var editSkillWildDiceError))
{
<p class="field-error">@editSkillWildDiceError</p>
}
<label for="skill-edit-allow-fumble">Allow fumble</label>
<input id="skill-edit-allow-fumble" type="checkbox" @bind="EditSkillForm.AllowFumble" />
}
<div class="inline-actions">
<button type="submit" disabled="@IsMutating">Save Skill</button>
<button type="button" class="ghost" @onclick="CloseSkillModals">Cancel</button>

View File

@@ -79,6 +79,7 @@ public partial class Home
private SkillSummary? SelectedSkill => SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == SelectedSkillId);
private string? ActiveCharacterName => SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == ActiveCharacterId)?.Name;
private bool IsCurrentUserGm => SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id;
private bool IsSelectedCampaignD6 => string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase);
private List<SkillSummary> SelectedCharacterSkills =>
SelectedCampaign is null || !SelectedCharacterId.HasValue
@@ -640,6 +641,8 @@ public partial class Home
{
SkillForm.Name = string.Empty;
SkillForm.DiceRollDefinition = string.Empty;
SkillForm.WildDice = IsSelectedCampaignD6 ? 1 : 0;
SkillForm.AllowFumble = IsSelectedCampaignD6;
SkillErrors.Clear();
SkillFormError = null;
ShowCreateSkillModal = true;
@@ -655,6 +658,8 @@ public partial class Home
EditingSkillId = SelectedSkill.Id;
EditSkillForm.Name = SelectedSkill.Name;
EditSkillForm.DiceRollDefinition = SelectedSkill.DiceRollDefinition;
EditSkillForm.WildDice = SelectedSkill.WildDice;
EditSkillForm.AllowFumble = SelectedSkill.AllowFumble;
EditSkillErrors.Clear();
EditSkillFormError = null;
ShowEditSkillModal = true;
@@ -688,6 +693,11 @@ public partial class Home
SkillErrors["diceRollDefinition"] = "Expression is required.";
}
if (IsSelectedCampaignD6 && SkillForm.WildDice < 1)
{
SkillErrors["wildDice"] = "D6 skills require at least one wild die.";
}
if (SkillErrors.Count > 0)
{
SkillFormError = "Resolve validation issues before submitting.";
@@ -697,7 +707,10 @@ public partial class Home
IsMutating = true;
try
{
_ = await RequestAsync<SkillSummary>("POST", $"/api/characters/{SelectedCharacter.Id}/skills", new CreateSkillRequest(SkillForm.Name.Trim(), SkillForm.DiceRollDefinition.Trim()));
_ = await RequestAsync<SkillSummary>(
"POST",
$"/api/characters/{SelectedCharacter.Id}/skills",
new CreateSkillRequest(SkillForm.Name.Trim(), SkillForm.DiceRollDefinition.Trim(), SkillForm.WildDice, SkillForm.AllowFumble));
CloseSkillModals();
await RefreshCampaignScopeAsync();
SetStatus("Skill created.", false);
@@ -733,6 +746,11 @@ public partial class Home
EditSkillErrors["diceRollDefinition"] = "Expression is required.";
}
if (IsSelectedCampaignD6 && EditSkillForm.WildDice < 1)
{
EditSkillErrors["wildDice"] = "D6 skills require at least one wild die.";
}
if (EditSkillErrors.Count > 0)
{
EditSkillFormError = "Resolve validation issues before submitting.";
@@ -742,7 +760,10 @@ public partial class Home
IsMutating = true;
try
{
var updatedSkill = await RequestAsync<SkillSummary>("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(EditSkillForm.Name.Trim(), EditSkillForm.DiceRollDefinition.Trim()));
var updatedSkill = await RequestAsync<SkillSummary>(
"PUT",
$"/api/skills/{EditingSkillId.Value}",
new UpdateSkillRequest(EditSkillForm.Name.Trim(), EditSkillForm.DiceRollDefinition.Trim(), EditSkillForm.WildDice, EditSkillForm.AllowFumble));
SelectedSkillId = updatedSkill.Id;
CloseSkillModals();
await RefreshCampaignScopeAsync();
@@ -985,6 +1006,93 @@ public partial class Home
return SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == skillId)?.Name ?? "Hidden skill";
}
private string SkillDefinitionLabel(SkillSummary skill)
{
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
{
return skill.DiceRollDefinition;
}
var fumble = skill.AllowFumble ? "fumble on" : "fumble off";
return $"{skill.DiceRollDefinition} | wild {skill.WildDice}, {fumble}";
}
private static string RollDieGlyph(int roll)
{
return roll switch
{
1 => "\u2680",
2 => "\u2681",
3 => "\u2682",
4 => "\u2683",
5 => "\u2684",
6 => "\u2685",
_ => roll.ToString()
};
}
private static string RollDieCssClass(RollDieResult die)
{
var classes = new List<string> { "die-chip" };
if (die.Wild)
{
classes.Add("wild");
}
if (die.Crit)
{
classes.Add("crit");
}
if (die.Fumble)
{
classes.Add("fumble");
}
if (die.Removed)
{
classes.Add("removed");
}
if (die.Added)
{
classes.Add("added");
}
return string.Join(" ", classes);
}
private static string RollDieTitle(RollDieResult die)
{
var labels = new List<string> { $"Roll {die.Roll}" };
if (die.Wild)
{
labels.Add("wild");
}
if (die.Crit)
{
labels.Add("critical");
}
if (die.Fumble)
{
labels.Add("fumble");
}
if (die.Removed)
{
labels.Add("removed");
}
if (die.Added)
{
labels.Add("added");
}
return string.Join(", ", labels);
}
private string RollerLabel(CampaignLogEntry entry)
{
if (User is not null && entry.RollerUserId == User.Id)
@@ -1134,6 +1242,8 @@ public partial class Home
{
public string Name { get; set; } = string.Empty;
public string DiceRollDefinition { get; set; } = string.Empty;
public int WildDice { get; set; }
public bool AllowFumble { get; set; }
}
private sealed class JsApiResponse

View File

@@ -25,11 +25,12 @@ public sealed record CreateCharacterRequest(string Name, Guid CampaignId);
public sealed record UpdateCharacterRequest(string Name, Guid CampaignId);
public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid CampaignId);
public sealed record CreateSkillRequest(string Name, string DiceRollDefinition);
public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition);
public sealed record SkillSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition);
public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
public sealed record SkillSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
public sealed record RollSkillRequest(string Visibility);
public sealed record RollDieResult(int Roll, bool Crit, bool Fumble, bool Wild, bool Removed, bool Added);
public sealed record RollResult(
Guid RollId,
Guid CampaignId,
@@ -39,6 +40,7 @@ public sealed record RollResult(
string Visibility,
int Result,
string Breakdown,
IReadOnlyList<RollDieResult> Dice,
DateTimeOffset TimestampUtc);
public sealed record CampaignLogEntry(

View File

@@ -59,6 +59,8 @@ public sealed class RpgRollerDbContext : DbContext
entity.HasKey(x => x.Id);
entity.Property(x => x.Name).IsRequired().HasMaxLength(128);
entity.Property(x => x.DiceRollDefinition).IsRequired().HasMaxLength(128);
entity.Property(x => x.WildDice).IsRequired();
entity.Property(x => x.AllowFumble).IsRequired();
entity.HasIndex(x => x.CharacterId);
});

View File

@@ -52,6 +52,8 @@ public sealed class Skill
public required Guid CharacterId { get; set; }
public required string Name { get; set; }
public required string DiceRollDefinition { get; set; }
public required int WildDice { get; set; }
public required bool AllowFumble { get; set; }
}
public sealed class RollLogEntry

View File

@@ -399,7 +399,7 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition)
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble)
{
if (string.IsNullOrWhiteSpace(name))
{
@@ -431,12 +431,20 @@ public sealed class GameService : IGameService
return ServiceResult<SkillSummary>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
}
var optionsValidation = ValidateSkillOptions(campaign.Ruleset, wildDice, allowFumble);
if (!optionsValidation.Succeeded)
{
return ServiceResult<SkillSummary>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
}
var skill = new Skill
{
Id = Guid.NewGuid(),
CharacterId = character.Id,
Name = name.Trim(),
DiceRollDefinition = expressionValidation.Value!.Canonical
DiceRollDefinition = expressionValidation.Value!.Canonical,
WildDice = optionsValidation.Value!.WildDice,
AllowFumble = optionsValidation.Value.AllowFumble
};
m_SkillsById[skill.Id] = skill;
@@ -447,7 +455,7 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition)
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble)
{
if (string.IsNullOrWhiteSpace(name))
{
@@ -480,8 +488,16 @@ public sealed class GameService : IGameService
return ServiceResult<SkillSummary>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
}
var optionsValidation = ValidateSkillOptions(campaign.Ruleset, wildDice, allowFumble);
if (!optionsValidation.Succeeded)
{
return ServiceResult<SkillSummary>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
}
skill.Name = name.Trim();
skill.DiceRollDefinition = expressionValidation.Value!.Canonical;
skill.WildDice = optionsValidation.Value!.WildDice;
skill.AllowFumble = optionsValidation.Value.AllowFumble;
TouchCampaignLocked(campaign.Id);
PersistStateLocked();
@@ -523,7 +539,7 @@ public sealed class GameService : IGameService
return ServiceResult<RollResult>.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message);
}
var roll = ComputeRoll(parsedExpression.Value!);
var roll = ComputeRoll(campaign.Ruleset, parsedExpression.Value!, skill);
var entry = new RollLogEntry
{
Id = Guid.NewGuid(),
@@ -541,7 +557,7 @@ public sealed class GameService : IGameService
TouchCampaignLocked(campaign.Id);
PersistStateLocked();
return ServiceResult<RollResult>.Success(ToRollResult(entry));
return ServiceResult<RollResult>.Success(ToRollResult(entry, roll.Dice));
}
}
@@ -582,20 +598,143 @@ public sealed class GameService : IGameService
}
}
private (int Total, string Breakdown) ComputeRoll(DiceExpression expression)
private static ServiceResult<(int WildDice, bool AllowFumble)> ValidateSkillOptions(RulesetKind ruleset, int wildDice, bool allowFumble)
{
if (wildDice < 0 || wildDice > 50)
{
return ServiceResult<(int WildDice, bool AllowFumble)>.Failure("invalid_wild_dice", "Wild dice must be between 0 and 50.");
}
if (ruleset == RulesetKind.D6)
{
if (wildDice < 1)
{
return ServiceResult<(int WildDice, bool AllowFumble)>.Failure("invalid_wild_dice", "D6 skills require at least one wild die.");
}
return ServiceResult<(int WildDice, bool AllowFumble)>.Success((wildDice, allowFumble));
}
return ServiceResult<(int WildDice, bool AllowFumble)>.Success((0, false));
}
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);
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeStandardRoll(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] = new RollDieResult(value, false, false, false, false, false);
total += value;
}
var modifierPart = expression.Modifier > 0 ? $"+{expression.Modifier}" : string.Empty;
var breakdown = $"{string.Join("+", diceValues)}{modifierPart}={total}";
return (total, breakdown);
return (total, BuildBreakdown(diceValues, expression.Modifier, total), dice);
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeD6Roll(DiceExpression expression, int wildDice, bool allowFumble)
{
var initialDice = expression.DiceCount;
var currentDice = initialDice;
var pendingExplodingDice = 0;
var pendingFumbles = 0;
var dieResults = new List<RollDieResult>(initialDice);
for (var i = 0; i < currentDice; i += 1)
{
var roll = m_DiceRoller.Roll(expression.Sides);
var isWild = i < wildDice;
var isCrit = false;
var isFumble = false;
var isAdded = false;
if (isWild)
{
if (roll == expression.Sides)
{
pendingExplodingDice += 1;
currentDice += 1;
isCrit = true;
}
else if (allowFumble && roll == 1)
{
pendingFumbles += 1;
isFumble = true;
}
}
if (pendingExplodingDice > 0 && i >= initialDice)
{
pendingExplodingDice -= 1;
isAdded = true;
if (roll == expression.Sides)
{
pendingExplodingDice += 1;
currentDice += 1;
}
}
dieResults.Add(new RollDieResult(roll, isCrit, isFumble, isWild, false, isAdded));
}
for (var roll = expression.Sides; roll >= 1 && pendingFumbles > 0; roll -= 1)
{
for (var i = currentDice - 1; i >= 0 && pendingFumbles > 0; i -= 1)
{
if (dieResults[i].Roll != roll)
{
continue;
}
dieResults[i] = dieResults[i] with
{
Removed = true,
Added = false,
Crit = false,
Fumble = false
};
pendingFumbles -= 1;
}
}
var total = expression.Modifier;
var includedDice = new List<int>(dieResults.Count);
foreach (var die in dieResults)
{
if (die.Fumble)
{
total += 1;
includedDice.Add(1);
}
else if (!die.Removed)
{
total += die.Roll;
includedDice.Add(die.Roll);
}
}
return (total, BuildBreakdown(includedDice, expression.Modifier, total), dieResults);
}
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}";
}
private ServiceResult<RollVisibility> ParseVisibility(string visibility)
@@ -651,10 +790,10 @@ public sealed class GameService : IGameService
private static SkillSummary ToSkillSummary(Skill skill)
{
return new SkillSummary(skill.Id, skill.CharacterId, skill.Name, skill.DiceRollDefinition);
return new SkillSummary(skill.Id, skill.CharacterId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble);
}
private static RollResult ToRollResult(RollLogEntry entry)
private static RollResult ToRollResult(RollLogEntry entry, IReadOnlyList<RollDieResult> dice)
{
return new RollResult(
entry.Id,
@@ -665,6 +804,7 @@ public sealed class GameService : IGameService
entry.Visibility == RollVisibility.Public ? "public" : "private",
entry.Result,
entry.Breakdown,
dice,
entry.TimestampUtc);
}
@@ -905,7 +1045,9 @@ public sealed class GameService : IGameService
Id = skill.Id,
CharacterId = skill.CharacterId,
Name = skill.Name,
DiceRollDefinition = skill.DiceRollDefinition
DiceRollDefinition = skill.DiceRollDefinition,
WildDice = skill.WildDice,
AllowFumble = skill.AllowFumble
};
}

View File

@@ -22,8 +22,8 @@ public interface IGameService
ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId);
ServiceResult<IReadOnlyList<CharacterSummary>> GetCurrentCampaignCharacters(string sessionToken);
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition);
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition);
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble);
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble);
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility);
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);

View File

@@ -297,6 +297,54 @@ select:focus-visible {
margin: 0;
}
.roll-dice-strip {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.die-chip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.1rem;
height: 2.1rem;
border: 2px solid #2a2418;
border-radius: 0.45rem;
background: #ffffff;
color: #1f1a13;
font-size: 1.45rem;
font-weight: 700;
line-height: 1;
}
.die-chip.wild {
border-width: 3px;
border-color: #c79913;
}
.die-chip.crit {
background: #d8ffc2;
color: #18490f;
}
.die-chip.fumble {
background: #ffb5a8;
color: #661110;
}
.die-chip.added {
background: #dbffdf;
color: #206029;
}
.die-chip.removed {
background: #fde0dd;
color: #7f5f55;
border-style: dashed;
text-decoration: line-through;
}
.empty,
.muted {
color: var(--muted);