Implement d6 wild dice/fumble skills and die-state rolls
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user