Implement d6 wild dice/fumble skills and die-state rolls
This commit is contained in:
9
FAQ.md
9
FAQ.md
@@ -41,3 +41,12 @@ Coverage now includes the entire backend project (`RpgRoller`), including API/ho
|
||||
## Why do backend services avoid API request DTO dependencies?
|
||||
|
||||
Service workflows accept explicit parameters (for example, `CreateCampaign(sessionToken, name, rulesetId)`) instead of API request DTOs. This keeps the service layer independent from HTTP transport contracts while avoiding extra service-only wrapper command types.
|
||||
|
||||
## How do d6 wild dice and fumbles work now?
|
||||
|
||||
d6 skills now store two explicit options:
|
||||
|
||||
- `wildDice`: number of wild dice for the skill
|
||||
- `allowFumble`: whether wild dice rolling `1` can trigger fumble removal
|
||||
|
||||
Roll responses also include per-die state flags (`crit`, `fumble`, `wild`, `removed`, `added`) so the frontend can render the full die-by-die outcome, not just the final total.
|
||||
|
||||
@@ -15,7 +15,7 @@ Tracking against `UX.md` tasks and decisions.
|
||||
| 9.1 App load + session restore | Implemented | Health check on load, rulesets/session load, unauthorized session reset, API unhealthy retry banner. |
|
||||
| 9.2 Authentication view | Implemented | Register/login cards, required validation, register password length check, server-error display. |
|
||||
| 9.3 Shared authenticated header | Implemented | User chip, campaign/active context, connection state, screen switch, refresh, logout. |
|
||||
| 9.4 Play screen character column | Implemented | Character icon tabs, sheet, modal edit/create flows, activate action, skill list, roll controls, last roll card. |
|
||||
| 9.4 Play screen character column | Implemented | Character icon tabs, sheet, modal edit/create flows, activate action, skill list, d6 skill options (wild/fumble), roll controls, and die-state visualized last roll card. |
|
||||
| 9.5 Play screen log column | Implemented | Chronological feed, private/public badges, private perspective styles (roller vs GM), local time + ISO tooltip. |
|
||||
| 9.6 Campaign management screen | Implemented | Campaign selector/summary, create form, details card, character management actions with modal edit pattern. |
|
||||
| 9.7 Tablet/mobile bottom bar | Implemented | `Character` / `Log` panel switch in play screen and per-tab session persistence. |
|
||||
|
||||
@@ -83,8 +83,8 @@ To use a custom SQLite database path, set `ConnectionStrings__RpgRoller`.
|
||||
- Rulesets: d6 and dnd5e validation rules
|
||||
- Campaigns: create/list/read
|
||||
- Characters: create/update/activate/current-campaign list
|
||||
- Skills: create/update with ruleset-aware dice expression validation
|
||||
- Rolls: public/private skill rolls with append-only campaign log
|
||||
- Skills: create/update with ruleset-aware dice expression validation and d6 wild-dice/fumble options
|
||||
- Rolls: public/private skill rolls with append-only campaign log; d6 rolls include wild/crit/fumble/add/remove die-state payloads
|
||||
- State stream: SSE endpoint for campaign version updates
|
||||
|
||||
## Implemented Frontend Scope
|
||||
@@ -94,8 +94,9 @@ To use a custom SQLite database path, set `ConnectionStrings__RpgRoller`.
|
||||
- play screen and campaign management screen switch
|
||||
- campaign creation and selection
|
||||
- character create/edit/activate via modal forms
|
||||
- skill create/edit via modal forms
|
||||
- skill create/edit via modal forms including d6 wild dice + allow-fumble controls
|
||||
- public/private rolling and campaign log viewing
|
||||
- die-state visualization in Last Roll (critical, fumble, wild, removed, added)
|
||||
- responsive play UX:
|
||||
- desktop two-column (character + log)
|
||||
- tablet/mobile panel switching with bottom tab bar (`Character` / `Log`)
|
||||
|
||||
@@ -98,6 +98,8 @@
|
||||
|
||||
* name
|
||||
* diceRollDefinition (ruleset-compliant expression, e.g. `5D+4`, `2d12+2`)
|
||||
* wildDice (d6 only; number of wild dice)
|
||||
* allowFumble (d6 only; whether wild-1 fumbles remove dice)
|
||||
* Behavior:
|
||||
|
||||
* Can be rolled
|
||||
@@ -198,6 +200,7 @@
|
||||
* System must:
|
||||
|
||||
* Validate dice expressions against the campaign ruleset
|
||||
* Validate d6 skill options (`wildDice`, `allowFumble`) as part of skill create/edit
|
||||
|
||||
---
|
||||
|
||||
@@ -213,7 +216,9 @@
|
||||
* System must:
|
||||
|
||||
* Evaluate dice expressions deterministically and fairly
|
||||
* For d6 skills, apply wild-die explosions and fumble-removal logic
|
||||
* Record all rolls in the campaign log
|
||||
* Return die-by-die roll states so the frontend can visualize critical/fumble/wild/removed/added outcomes
|
||||
|
||||
---
|
||||
|
||||
@@ -263,6 +268,7 @@
|
||||
### Skills
|
||||
|
||||
* As a **player**, I want to define skills with dice formulas so that I can perform actions.
|
||||
* As a **player**, I want to configure wild dice and fumble behavior for d6 skills so the roll follows my table rules.
|
||||
* As a **GM**, I want to edit character skills so that I can enforce or adjust rules.
|
||||
|
||||
---
|
||||
@@ -270,6 +276,7 @@
|
||||
### Dice Rolling
|
||||
|
||||
* As a **player**, I want to roll dice for a skill so that I can resolve actions.
|
||||
* As a **player**, I want to see which dice were wild, exploded, fumbled, removed, or added so that I can audit the roll result.
|
||||
* As a **user**, I want to choose whether a roll is public or private so that I can control information visibility.
|
||||
* As a **GM**, I want to see all rolls (including private ones) so that I can oversee the game.
|
||||
|
||||
|
||||
@@ -34,19 +34,23 @@ public sealed class CampaignApiTests : ApiTestBase
|
||||
var createdSkill = await PostAsync<CreateSkillRequest, SkillSummary>(
|
||||
gmClient,
|
||||
$"/api/characters/{gmCharacter.Id}/skills",
|
||||
new CreateSkillRequest("Arcana", "2d12+2"));
|
||||
new CreateSkillRequest("Arcana", "2d12+2", 0, false));
|
||||
Assert.Equal("2d12+2", createdSkill.DiceRollDefinition);
|
||||
Assert.Equal(0, createdSkill.WildDice);
|
||||
Assert.False(createdSkill.AllowFumble);
|
||||
|
||||
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(
|
||||
gmClient,
|
||||
$"/api/skills/{createdSkill.Id}",
|
||||
new UpdateSkillRequest("Arcana Mastery", "2d12+3"));
|
||||
new UpdateSkillRequest("Arcana Mastery", "2d12+3", 0, false));
|
||||
Assert.Equal("Arcana Mastery", updatedSkill.Name);
|
||||
Assert.Equal("2d12+3", updatedSkill.DiceRollDefinition);
|
||||
Assert.Equal(0, updatedSkill.WildDice);
|
||||
Assert.False(updatedSkill.AllowFumble);
|
||||
|
||||
var invalidSkill = await gmClient.PostAsJsonAsync(
|
||||
$"/api/characters/{gmCharacter.Id}/skills",
|
||||
new CreateSkillRequest("Broken", "5D+4"));
|
||||
new CreateSkillRequest("Broken", "5D+4", 0, false));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, invalidSkill.StatusCode);
|
||||
|
||||
var details = await GetAsync<CampaignDetails>(gmClient, $"/api/campaigns/{campaign.Id}");
|
||||
|
||||
@@ -35,7 +35,9 @@ public sealed class RollVisibilityApiTests : ApiTestBase
|
||||
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(
|
||||
playerClient,
|
||||
$"/api/characters/{playerCharacter.Id}/skills",
|
||||
new CreateSkillRequest("Stealth", "2D+1"));
|
||||
new CreateSkillRequest("Stealth", "2D+1", 1, true));
|
||||
Assert.Equal(1, skill.WildDice);
|
||||
Assert.True(skill.AllowFumble);
|
||||
|
||||
await RegisterAsync(observerClient, "observer", "Password123", "Observer");
|
||||
await LoginAsync(observerClient, "observer", "Password123");
|
||||
|
||||
@@ -88,8 +88,8 @@ public sealed class ServiceCampaignTests
|
||||
var ownerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Owner Character", campaign.Id));
|
||||
var otherCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(otherSession, "Other Character", campaign.Id));
|
||||
|
||||
var ownerSkill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1"));
|
||||
_ = ServiceTestSupport.GetValue(service.CreateSkill(otherSession, otherCharacter.Id, "Perception", "1D+2"));
|
||||
var ownerSkill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true));
|
||||
_ = ServiceTestSupport.GetValue(service.CreateSkill(otherSession, otherCharacter.Id, "Perception", "1D+2", 1, true));
|
||||
|
||||
var ownerView = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id));
|
||||
Assert.Single(ownerView.Characters);
|
||||
|
||||
80
RpgRoller.Tests/Services/ServiceD6RollTests.cs
Normal file
80
RpgRoller.Tests/Services/ServiceD6RollTests.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class ServiceD6RollTests
|
||||
{
|
||||
[Fact]
|
||||
public void RollSkill_D6WildCritical_AddsExtraDieAndTracksFlags()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(6, 4, 2);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Main", "d6"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Blaster", "2D+1", 1, true));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||
|
||||
Assert.Equal(13, roll.Result);
|
||||
Assert.Equal("6+4+2+1=13", roll.Breakdown);
|
||||
Assert.Equal(3, roll.Dice.Count);
|
||||
Assert.True(roll.Dice[0].Wild);
|
||||
Assert.True(roll.Dice[0].Crit);
|
||||
Assert.True(roll.Dice[2].Added);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_D6Fumble_RemovesHighestDieAndPreservesFumbleDie()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(1, 3, 6, 1);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Main", "d6"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Brawl", "3D", 1, true));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||
|
||||
Assert.Equal(4, roll.Result);
|
||||
Assert.Equal("1+3=4", roll.Breakdown);
|
||||
Assert.Equal(3, roll.Dice.Count);
|
||||
Assert.True(roll.Dice[0].Fumble);
|
||||
Assert.True(roll.Dice[0].Wild);
|
||||
Assert.True(roll.Dice[2].Removed);
|
||||
Assert.False(roll.Dice[2].Crit);
|
||||
Assert.False(roll.Dice[2].Fumble);
|
||||
|
||||
var noFumbleSkill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Calm", "1D", 1, false));
|
||||
var noFumbleRoll = ServiceTestSupport.GetValue(service.RollSkill(session, noFumbleSkill.Id, "public"));
|
||||
Assert.False(noFumbleRoll.Dice[0].Fumble);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkillOptions_AreValidatedForD6AndIgnoredForDnd5e()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(2, 2);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
|
||||
|
||||
var d6Campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "D6", "d6"));
|
||||
var d6Character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "D6 Hero", d6Campaign.Id));
|
||||
|
||||
var missingWildDice = service.CreateSkill(session, d6Character.Id, "Broken", "2D+1", 0, true);
|
||||
Assert.False(missingWildDice.Succeeded);
|
||||
|
||||
var tooManyWildDice = service.CreateSkill(session, d6Character.Id, "Broken 2", "2D+1", 51, true);
|
||||
Assert.False(tooManyWildDice.Succeeded);
|
||||
|
||||
var dndCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "DND", "dnd5e"));
|
||||
var dndCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Mage", dndCampaign.Id));
|
||||
var dndSkill = ServiceTestSupport.GetValue(service.CreateSkill(session, dndCharacter.Id, "Arcana", "1d20+2", 5, true));
|
||||
|
||||
Assert.Equal(0, dndSkill.WildDice);
|
||||
Assert.False(dndSkill.AllowFumble);
|
||||
}
|
||||
}
|
||||
@@ -34,9 +34,9 @@ public sealed class ServicePersistenceTests
|
||||
Assert.False(service.ActivateCharacter(string.Empty, ownerCharacter.Id).Succeeded);
|
||||
Assert.False(service.ActivateCharacter(gmSession, ownerCharacter.Id).Succeeded);
|
||||
Assert.False(service.GetCurrentCampaignCharacters(string.Empty).Succeeded);
|
||||
Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, "Stealth", "2D+1").Succeeded);
|
||||
Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), "Stealth", "2D+1").Succeeded);
|
||||
Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, "Stealth", "2D+1").Succeeded);
|
||||
Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded);
|
||||
Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), "Stealth", "2D+1", 1, true).Succeeded);
|
||||
Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded);
|
||||
|
||||
using (var db = harness.CreateDbContext())
|
||||
{
|
||||
@@ -74,10 +74,10 @@ public sealed class ServicePersistenceTests
|
||||
Assert.Null(db.Users.Single(u => u.UsernameNormalized == "OWNER").ActiveCharacterId);
|
||||
}
|
||||
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1"));
|
||||
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "", "2D+1").Succeeded);
|
||||
Assert.False(service.UpdateSkill(string.Empty, skill.Id, "Stealth", "2D+1").Succeeded);
|
||||
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "Stealth", "bad").Succeeded);
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true));
|
||||
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "", "2D+1", 1, true).Succeeded);
|
||||
Assert.False(service.UpdateSkill(string.Empty, skill.Id, "Stealth", "2D+1", 1, true).Succeeded);
|
||||
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "Stealth", "bad", 1, true).Succeeded);
|
||||
Assert.False(service.RollSkill(string.Empty, skill.Id, "public").Succeeded);
|
||||
|
||||
using (var db = harness.CreateDbContext())
|
||||
|
||||
@@ -27,21 +27,21 @@ public sealed class ServiceSkillRollTests
|
||||
var missingTargetCampaign = service.UpdateCharacter(ownerSession, character.Id, "Renamed", Guid.NewGuid());
|
||||
Assert.False(missingTargetCampaign.Succeeded);
|
||||
|
||||
var noSkillName = service.CreateSkill(ownerSession, character.Id, "", "1d20");
|
||||
var noSkillName = service.CreateSkill(ownerSession, character.Id, "", "1d20", 0, false);
|
||||
Assert.False(noSkillName.Succeeded);
|
||||
|
||||
var invalidExpression = service.CreateSkill(ownerSession, character.Id, "Skill", "5D+4");
|
||||
var invalidExpression = service.CreateSkill(ownerSession, character.Id, "Skill", "5D+4", 0, false);
|
||||
Assert.False(invalidExpression.Succeeded);
|
||||
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Skill", "1d20+2"));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Skill", "1d20+2", 0, false));
|
||||
|
||||
var missingSkillUpdate = service.UpdateSkill(ownerSession, Guid.NewGuid(), "X", "1d20");
|
||||
var missingSkillUpdate = service.UpdateSkill(ownerSession, Guid.NewGuid(), "X", "1d20", 0, false);
|
||||
Assert.False(missingSkillUpdate.Succeeded);
|
||||
|
||||
var forbiddenSkillUpdate = service.UpdateSkill(otherSession, skill.Id, "X", "1d20");
|
||||
var forbiddenSkillUpdate = service.UpdateSkill(otherSession, skill.Id, "X", "1d20", 0, false);
|
||||
Assert.False(forbiddenSkillUpdate.Succeeded);
|
||||
|
||||
var gmSkillUpdate = service.UpdateSkill(gmSession, skill.Id, "GM Edit", "2d6+1");
|
||||
var gmSkillUpdate = service.UpdateSkill(gmSession, skill.Id, "GM Edit", "2d6+1", 0, false);
|
||||
Assert.True(gmSkillUpdate.Succeeded);
|
||||
|
||||
var missingRoll = service.RollSkill(ownerSession, Guid.NewGuid(), "public");
|
||||
|
||||
@@ -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);
|
||||
|
||||
4
TECH.md
4
TECH.md
@@ -12,8 +12,8 @@
|
||||
- Local CI parity entrypoint: `scripts/ci-local.ps1`
|
||||
- API endpoint modules: `RpgRoller/Api/*Endpoints.cs` + shared session/auth helpers
|
||||
- Service boundary model: API request DTOs are mapped to explicit service method parameters before workflow execution
|
||||
- Current backend features: auth/session, campaign/character/skill management, ruleset-aware rolls, filtered campaign logs, and SSE state updates.
|
||||
- Current frontend features: Blazor-based authenticated campaign workspace with live log updates and full roll workflow controls.
|
||||
- Current backend features: auth/session, campaign/character/skill management (including d6 wild-dice/fumble skill options), ruleset-aware rolls, filtered campaign logs, and SSE state updates.
|
||||
- Current frontend features: Blazor-based authenticated campaign workspace with live log updates, full roll workflow controls, and die-state visualization for roll outcomes.
|
||||
|
||||
## 1) Stack and baseline choices
|
||||
|
||||
|
||||
4
UX.md
4
UX.md
@@ -250,6 +250,9 @@ Actions:
|
||||
|
||||
- `Create Skill` button -> modal form
|
||||
- `Edit Skill` button -> modal form
|
||||
- d6 skill forms include:
|
||||
- `Wild dice` numeric field
|
||||
- `Allow fumble` toggle
|
||||
- Roll command panel:
|
||||
- Visibility selector (`public`, `private`)
|
||||
- `Roll Skill` primary action
|
||||
@@ -258,6 +261,7 @@ Last roll card:
|
||||
|
||||
- Result total
|
||||
- Breakdown
|
||||
- Die-by-die visualization with states: `critical`, `fumble`, `wild`, `removed`, `added`
|
||||
- Visibility
|
||||
- Timestamp
|
||||
|
||||
|
||||
@@ -893,11 +893,20 @@
|
||||
},
|
||||
"diceRollDefinition": {
|
||||
"type": "string"
|
||||
},
|
||||
"wildDice": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"allowFumble": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"diceRollDefinition"
|
||||
"diceRollDefinition",
|
||||
"wildDice",
|
||||
"allowFumble"
|
||||
]
|
||||
},
|
||||
"UpdateSkillRequest": {
|
||||
@@ -908,11 +917,20 @@
|
||||
},
|
||||
"diceRollDefinition": {
|
||||
"type": "string"
|
||||
},
|
||||
"wildDice": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"allowFumble": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"diceRollDefinition"
|
||||
"diceRollDefinition",
|
||||
"wildDice",
|
||||
"allowFumble"
|
||||
]
|
||||
},
|
||||
"SkillSummary": {
|
||||
@@ -931,13 +949,22 @@
|
||||
},
|
||||
"diceRollDefinition": {
|
||||
"type": "string"
|
||||
},
|
||||
"wildDice": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"allowFumble": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"characterId",
|
||||
"name",
|
||||
"diceRollDefinition"
|
||||
"diceRollDefinition",
|
||||
"wildDice",
|
||||
"allowFumble"
|
||||
]
|
||||
},
|
||||
"RollSkillRequest": {
|
||||
@@ -951,6 +978,38 @@
|
||||
"visibility"
|
||||
]
|
||||
},
|
||||
"RollDieResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"roll": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"crit": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"fumble": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"wild": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"removed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"added": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"roll",
|
||||
"crit",
|
||||
"fumble",
|
||||
"wild",
|
||||
"removed",
|
||||
"added"
|
||||
]
|
||||
},
|
||||
"RollResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -984,6 +1043,12 @@
|
||||
"breakdown": {
|
||||
"type": "string"
|
||||
},
|
||||
"dice": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RollDieResult"
|
||||
}
|
||||
},
|
||||
"timestampUtc": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
@@ -998,6 +1063,7 @@
|
||||
"visibility",
|
||||
"result",
|
||||
"breakdown",
|
||||
"dice",
|
||||
"timestampUtc"
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user