Persist Rolemaster fumble range

This commit is contained in:
2026-04-03 00:32:17 +02:00
parent 90afe3b06b
commit 48439fd21d
19 changed files with 654 additions and 56 deletions

View File

@@ -63,6 +63,8 @@ Gameplay capabilities now include:
- Shared top header control across all authenticated workspace screens (play, campaign management, admin) - Shared top header control across all authenticated workspace screens (play, campaign management, admin)
- Admin user management is integrated into workspace screen toggles (`Play`, `Campaign Management`, `Admin`) - Admin user management is integrated into workspace screen toggles (`Play`, `Campaign Management`, `Admin`)
- Rolemaster expression validation currently recognizes `2d10+48`, `d100+4`, and `d100!+85`, including Rolemaster-only negative modifiers such as `d100-15` - Rolemaster expression validation currently recognizes `2d10+48`, `d100+4`, and `d100!+85`, including Rolemaster-only negative modifiers such as `d100-15`
- Rolemaster open-ended percentile skills and skill-group defaults now persist a nullable `FumbleRange` field, while D6 and D&D rows migrate forward unchanged
- Startup migration coverage is validated against a copied temp-file instance of `RpgRoller/App_Data/rpgroller.development.db` before feature work is considered complete
## Prerequisites ## Prerequisites

View File

@@ -79,6 +79,35 @@ public sealed class CampaignApiTests : ApiTestBase
Assert.Equal("rolemaster", campaign.RulesetId); Assert.Equal("rolemaster", campaign.RulesetId);
} }
[Fact]
public async Task RolemasterSkillDefinitions_RoundTripFumbleRangeThroughApi()
{
using var factory = CreateFactory(88, 42, 17);
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
await RegisterAsync(gmClient, "gm-rm-skill", "Password123", "Game Master");
await LoginAsync(gmClient, "gm-rm-skill", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Shadow World", "rolemaster"));
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(gmClient, "/api/characters", new("Kalen", campaign.Id));
var missingFumbleRange = await gmClient.PostAsJsonAsync($"/api/characters/{character.Id}/skills", new CreateSkillRequest("Bad Open Ended", "d100!+35", 0, false));
Assert.Equal(HttpStatusCode.BadRequest, missingFumbleRange.StatusCode);
var group = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(gmClient, $"/api/characters/{character.Id}/skill-groups", new("Perception", "d100!+15", 0, false, 5));
Assert.Equal(5, group.FumbleRange);
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(gmClient, $"/api/characters/{character.Id}/skills", new("Awareness", "d100!+35", 0, false, group.Id, 3));
Assert.Equal(3, skill.FumbleRange);
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(gmClient, $"/api/skills/{skill.Id}", new("Awareness", "d100!+45", 0, false, group.Id, 4));
Assert.Equal(4, updatedSkill.FumbleRange);
var sheet = await GetAsync<CharacterSheet>(gmClient, $"/api/characters/{character.Id}/sheet");
Assert.Equal(5, Assert.Single(sheet.SkillGroups).FumbleRange);
Assert.Equal(4, Assert.Single(sheet.Skills).FumbleRange);
}
[Fact] [Fact]
public async Task SkillGroupsAndOwnerTransfer_WorkThroughApi() public async Task SkillGroupsAndOwnerTransfer_WorkThroughApi()
{ {

View File

@@ -1,7 +1,9 @@
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using RpgRoller.Data; using RpgRoller.Data;
using RpgRoller.Hosting; using RpgRoller.Hosting;
@@ -125,6 +127,16 @@ public sealed class HostingCoverageTests
Assert.Contains("WildDice", columns); Assert.Contains("WildDice", columns);
Assert.Contains("AllowFumble", columns); Assert.Contains("AllowFumble", columns);
Assert.Contains("FumbleRange", columns);
using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand();
skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');";
using var skillGroupsTableInfoReader = skillGroupsTableInfoCommand.ExecuteReader();
var skillGroupColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (skillGroupsTableInfoReader.Read())
skillGroupColumns.Add(skillGroupsTableInfoReader.GetString(1));
Assert.Contains("FumbleRange", skillGroupColumns);
using var rollTableInfoCommand = verifyConnection.CreateCommand(); using var rollTableInfoCommand = verifyConnection.CreateCommand();
rollTableInfoCommand.CommandText = "PRAGMA table_info('RollLogEntries');"; rollTableInfoCommand.CommandText = "PRAGMA table_info('RollLogEntries');";
@@ -183,5 +195,130 @@ public sealed class HostingCoverageTests
rolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226160859_AddAuthorizationRolesAndCampaignDeletion';"; rolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226160859_AddAuthorizationRolesAndCampaignDeletion';";
var rolesHistoryCount = Convert.ToInt32(rolesHistoryCommand.ExecuteScalar()); var rolesHistoryCount = Convert.ToInt32(rolesHistoryCommand.ExecuteScalar());
Assert.Equal(1, rolesHistoryCount); Assert.Equal(1, rolesHistoryCount);
using var rolemasterHistoryCommand = verifyConnection.CreateCommand();
rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';";
var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar());
Assert.Equal(1, rolemasterHistoryCount);
}
[Fact]
public void InitializeRpgRollerState_MigratesCopiedDevelopmentDatabaseAndPreservesD6Rolling()
{
var sourceDbPath = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "RpgRoller", "App_Data", "rpgroller.development.db");
var copiedDbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-dev-copy-{Guid.NewGuid():N}.db");
File.Copy(Path.GetFullPath(sourceDbPath), copiedDbPath, overwrite: true);
Guid skillId;
Guid ownerUserId;
Guid characterId;
var campaignCountBefore = 0;
var skillCountBefore = 0;
using (var connection = new SqliteConnection($"Data Source={copiedDbPath}"))
{
connection.Open();
using var countsCommand = connection.CreateCommand();
countsCommand.CommandText = """
SELECT (SELECT COUNT(*) FROM Campaigns),
(SELECT COUNT(*) FROM Skills);
""";
using var countsReader = countsCommand.ExecuteReader();
Assert.True(countsReader.Read());
campaignCountBefore = countsReader.GetInt32(0);
skillCountBefore = countsReader.GetInt32(1);
using var existingSkillCommand = connection.CreateCommand();
existingSkillCommand.CommandText = """
SELECT s.Id, c.OwnerUserId, c.Id
FROM Skills s
INNER JOIN Characters c ON c.Id = s.CharacterId
INNER JOIN Campaigns cp ON cp.Id = c.CampaignId
WHERE cp.Ruleset = 'D6'
ORDER BY s.Name
LIMIT 1;
""";
using var existingSkillReader = existingSkillCommand.ExecuteReader();
Assert.True(existingSkillReader.Read());
skillId = Guid.Parse(existingSkillReader.GetString(0));
ownerUserId = Guid.Parse(existingSkillReader.GetString(1));
characterId = Guid.Parse(existingSkillReader.GetString(2));
using var sessionCommand = connection.CreateCommand();
sessionCommand.CommandText = """
INSERT INTO Sessions ("Token", "UserId", "CreatedAtUtc")
VALUES ($token, $userId, $createdAtUtc);
""";
var tokenParameter = sessionCommand.CreateParameter();
tokenParameter.ParameterName = "$token";
tokenParameter.Value = "migration-test-session";
sessionCommand.Parameters.Add(tokenParameter);
var userParameter = sessionCommand.CreateParameter();
userParameter.ParameterName = "$userId";
userParameter.Value = ownerUserId.ToString();
sessionCommand.Parameters.Add(userParameter);
var createdAtParameter = sessionCommand.CreateParameter();
createdAtParameter.ParameterName = "$createdAtUtc";
createdAtParameter.Value = DateTimeOffset.UtcNow.ToString("O");
sessionCommand.Parameters.Add(createdAtParameter);
_ = sessionCommand.ExecuteNonQuery();
}
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
ContentRootPath = Path.GetTempPath(),
EnvironmentName = Environments.Development
});
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:RpgRoller"] = $"Data Source={copiedDbPath}"
});
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
using var app = builder.Build();
app.InitializeRpgRollerState();
using var scope = app.Services.CreateScope();
var game = scope.ServiceProvider.GetRequiredService<IGameService>();
var rollResult = game.RollSkill("migration-test-session", skillId, "public");
Assert.True(rollResult.Succeeded);
Assert.NotEmpty(ServiceTestSupport.GetValue(rollResult).Dice);
var migratedSheet = ServiceTestSupport.GetValue(game.GetCharacterSheet("migration-test-session", characterId));
Assert.Contains(migratedSheet.Skills, skill => skill.Id == skillId);
using var verifyConnection = new SqliteConnection($"Data Source={copiedDbPath}");
verifyConnection.Open();
using var countsAfterCommand = verifyConnection.CreateCommand();
countsAfterCommand.CommandText = """
SELECT (SELECT COUNT(*) FROM Campaigns),
(SELECT COUNT(*) FROM Skills);
""";
using var countsAfterReader = countsAfterCommand.ExecuteReader();
Assert.True(countsAfterReader.Read());
Assert.Equal(campaignCountBefore, countsAfterReader.GetInt32(0));
Assert.Equal(skillCountBefore, countsAfterReader.GetInt32(1));
using var skillsTableInfoCommand = verifyConnection.CreateCommand();
skillsTableInfoCommand.CommandText = "PRAGMA table_info('Skills');";
using var skillsTableInfoReader = skillsTableInfoCommand.ExecuteReader();
var skillColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (skillsTableInfoReader.Read())
skillColumns.Add(skillsTableInfoReader.GetString(1));
Assert.Contains("FumbleRange", skillColumns);
using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand();
skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');";
using var skillGroupsTableInfoReader = skillGroupsTableInfoCommand.ExecuteReader();
var skillGroupColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (skillGroupsTableInfoReader.Read())
skillGroupColumns.Add(skillGroupsTableInfoReader.GetString(1));
Assert.Contains("FumbleRange", skillGroupColumns);
} }
} }

View File

@@ -92,4 +92,31 @@ public sealed class ServicePersistenceTests
Assert.False(invalidExpressionHarness.Service.RollSkill(ownerSession, skill.Id, "public").Succeeded); Assert.False(invalidExpressionHarness.Service.RollSkill(ownerSession, skill.Id, "public").Succeeded);
Assert.False(service.GetCampaignLog(string.Empty, campaign.Id).Succeeded); Assert.False(service.GetCampaignLog(string.Empty, campaign.Id).Succeeded);
} }
[Fact]
public void RolemasterFumbleRange_PersistsAcrossDatabaseReload()
{
using var harness = ServiceTestSupport.CreateHarness();
var service = harness.Service;
service.Register("gm-rm-persist", "Password123", "GM");
service.Register("owner-rm-persist", "Password123", "Owner");
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-rm-persist", "Password123")).SessionToken;
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-rm-persist", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Persistence", "rolemaster"));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Loremaster", campaign.Id));
var group = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Perception", "d100!+25", 0, false, 5));
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Read Runes", "d100!+35", 0, false, group.Id, 3));
using var reloadedHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath);
var reloadedSheet = ServiceTestSupport.GetValue(reloadedHarness.Service.GetCharacterSheet(ownerSession, character.Id));
var reloadedGroup = Assert.Single(reloadedSheet.SkillGroups, current => current.Id == group.Id);
Assert.Equal(5, reloadedGroup.FumbleRange);
var reloadedSkill = Assert.Single(reloadedSheet.Skills, current => current.Id == skill.Id);
Assert.Equal(3, reloadedSkill.FumbleRange);
}
} }

View File

@@ -195,19 +195,34 @@ public sealed class ServiceSkillGroupAndOwnershipTests
var negativeDndSkill = service.CreateSkill(ownerSession, dndCharacter.Id, "Invalid", "1d20-1", 0, false); var negativeDndSkill = service.CreateSkill(ownerSession, dndCharacter.Id, "Invalid", "1d20-1", 0, false);
Assert.False(negativeDndSkill.Succeeded); Assert.False(negativeDndSkill.Succeeded);
var rolemasterGroup = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, rolemasterCharacter.Id, "Initiative", "2d10-15", 3, true)); var invalidRolemasterOptions = service.CreateSkillGroup(ownerSession, rolemasterCharacter.Id, "Invalid", "2d10-15", 3, true);
Assert.Equal("2d10-15", rolemasterGroup.DiceRollDefinition); Assert.False(invalidRolemasterOptions.Succeeded);
var rolemasterGroup = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, rolemasterCharacter.Id, "Awareness", "d100!+15", 0, false, 5));
Assert.Equal("d100!+15", rolemasterGroup.DiceRollDefinition);
Assert.Equal(0, rolemasterGroup.WildDice); Assert.Equal(0, rolemasterGroup.WildDice);
Assert.False(rolemasterGroup.AllowFumble); Assert.False(rolemasterGroup.AllowFumble);
Assert.Equal(5, rolemasterGroup.FumbleRange);
var percentileSkill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, rolemasterCharacter.Id, "Perception", "1d100-20", 4, true)); var percentileWithFumbleRange = service.CreateSkill(ownerSession, rolemasterCharacter.Id, "Bad Percentile", "1d100-20", 0, false, null, 5);
Assert.False(percentileWithFumbleRange.Succeeded);
var percentileSkill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, rolemasterCharacter.Id, "Perception", "1d100-20", 0, false, rolemasterGroup.Id));
Assert.Equal("d100-20", percentileSkill.DiceRollDefinition); Assert.Equal("d100-20", percentileSkill.DiceRollDefinition);
Assert.Equal(0, percentileSkill.WildDice); Assert.Equal(0, percentileSkill.WildDice);
Assert.False(percentileSkill.AllowFumble); Assert.False(percentileSkill.AllowFumble);
Assert.Null(percentileSkill.FumbleRange);
var openEndedSkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "1d100!+85", 5, true)); var missingOpenEndedFumbleRange = service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "1d100!+85", 0, false, rolemasterGroup.Id);
Assert.False(missingOpenEndedFumbleRange.Succeeded);
var invalidOpenEndedFumbleRange = service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "1d100!+85", 0, false, rolemasterGroup.Id, 96);
Assert.False(invalidOpenEndedFumbleRange.Succeeded);
var openEndedSkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "1d100!+85", 0, false, rolemasterGroup.Id, 5));
Assert.Equal("d100!+85", openEndedSkill.DiceRollDefinition); Assert.Equal("d100!+85", openEndedSkill.DiceRollDefinition);
Assert.Equal(0, openEndedSkill.WildDice); Assert.Equal(0, openEndedSkill.WildDice);
Assert.False(openEndedSkill.AllowFumble); Assert.False(openEndedSkill.AllowFumble);
Assert.Equal(5, openEndedSkill.FumbleRange);
} }
} }

View File

@@ -87,11 +87,11 @@ public sealed class WorkspaceQueryServiceTests
public ServiceResult<bool> DeleteCharacter(string sessionToken, Guid characterId) => throw new NotSupportedException(); public ServiceResult<bool> DeleteCharacter(string sessionToken, Guid characterId) => throw new NotSupportedException();
public ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId) => throw new NotSupportedException(); public ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId) => throw new NotSupportedException();
public ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken) => throw new NotSupportedException(); public ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken) => throw new NotSupportedException();
public ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble) => throw new NotSupportedException(); public ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null) => throw new NotSupportedException();
public ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble) => throw new NotSupportedException(); public ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null) => throw new NotSupportedException();
public ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId) => throw new NotSupportedException(); public ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId) => throw new NotSupportedException();
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null) => throw new NotSupportedException(); public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) => throw new NotSupportedException();
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null) => throw new NotSupportedException(); public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) => throw new NotSupportedException();
public ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId) => throw new NotSupportedException(); public ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId) => throw new NotSupportedException();
public ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId) => throw new NotSupportedException(); public ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId) => throw new NotSupportedException();
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility) => throw new NotSupportedException(); public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility) => throw new NotSupportedException();

View File

@@ -9,13 +9,13 @@ internal static class SkillEndpoints
{ {
group.MapPost("/characters/{characterId:guid}/skills", (Guid characterId, CreateSkillRequest request, HttpContext context, IGameService game) => 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, request.WildDice, request.AllowFumble, request.SkillGroupId); var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId, request.FumbleRange);
return ApiResultMapper.ToApiResult(result); return ApiResultMapper.ToApiResult(result);
}); });
group.MapPut("/skills/{skillId:guid}", (Guid skillId, UpdateSkillRequest request, HttpContext context, IGameService game) => group.MapPut("/skills/{skillId:guid}", (Guid skillId, UpdateSkillRequest request, HttpContext context, IGameService game) =>
{ {
var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId); var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId, request.FumbleRange);
return ApiResultMapper.ToApiResult(result); return ApiResultMapper.ToApiResult(result);
}); });
@@ -27,13 +27,13 @@ internal static class SkillEndpoints
group.MapPost("/characters/{characterId:guid}/skill-groups", (Guid characterId, CreateSkillGroupRequest request, HttpContext context, IGameService game) => group.MapPost("/characters/{characterId:guid}/skill-groups", (Guid characterId, CreateSkillGroupRequest request, HttpContext context, IGameService game) =>
{ {
var result = game.CreateSkillGroup(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble); var result = game.CreateSkillGroup(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.FumbleRange);
return ApiResultMapper.ToApiResult(result); return ApiResultMapper.ToApiResult(result);
}); });
group.MapPut("/skill-groups/{skillGroupId:guid}", (Guid skillGroupId, UpdateSkillGroupRequest request, HttpContext context, IGameService game) => group.MapPut("/skill-groups/{skillGroupId:guid}", (Guid skillGroupId, UpdateSkillGroupRequest request, HttpContext context, IGameService game) =>
{ {
var result = game.UpdateSkillGroup(context.GetRequiredSessionToken(), skillGroupId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble); var result = game.UpdateSkillGroup(context.GetRequiredSessionToken(), skillGroupId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.FumbleRange);
return ApiResultMapper.ToApiResult(result); return ApiResultMapper.ToApiResult(result);
}); });

View File

@@ -46,6 +46,7 @@ public sealed class SkillFormModel
public string SkillGroupId { get; set; } = string.Empty; public string SkillGroupId { get; set; } = string.Empty;
public int WildDice { get; set; } public int WildDice { get; set; }
public bool AllowFumble { get; set; } public bool AllowFumble { get; set; }
public int? FumbleRange { get; set; }
} }
public sealed class SkillGroupFormModel public sealed class SkillGroupFormModel
@@ -54,6 +55,7 @@ public sealed class SkillGroupFormModel
public string DiceRollDefinition { get; set; } = string.Empty; public string DiceRollDefinition { get; set; } = string.Empty;
public int WildDice { get; set; } public int WildDice { get; set; }
public bool AllowFumble { get; set; } public bool AllowFumble { get; set; }
public int? FumbleRange { get; set; }
} }
public enum HomeViewMode public enum HomeViewMode

View File

@@ -19,7 +19,8 @@ public partial class CharacterPanel
DiceRollDefinition = selectedGroup?.DiceRollDefinition ?? string.Empty, DiceRollDefinition = selectedGroup?.DiceRollDefinition ?? string.Empty,
SkillGroupId = selectedGroup?.Id.ToString() ?? string.Empty, SkillGroupId = selectedGroup?.Id.ToString() ?? string.Empty,
WildDice = selectedGroup?.WildDice ?? (IsD6 ? 1 : 0), WildDice = selectedGroup?.WildDice ?? (IsD6 ? 1 : 0),
AllowFumble = selectedGroup?.AllowFumble ?? IsD6 AllowFumble = selectedGroup?.AllowFumble ?? IsD6,
FumbleRange = selectedGroup?.FumbleRange
}; };
CreateSkillFormVersion++; CreateSkillFormVersion++;
@@ -35,7 +36,8 @@ public partial class CharacterPanel
DiceRollDefinition = skill.DiceRollDefinition, DiceRollDefinition = skill.DiceRollDefinition,
SkillGroupId = skill.SkillGroupId?.ToString() ?? string.Empty, SkillGroupId = skill.SkillGroupId?.ToString() ?? string.Empty,
WildDice = skill.WildDice, WildDice = skill.WildDice,
AllowFumble = skill.AllowFumble AllowFumble = skill.AllowFumble,
FumbleRange = skill.FumbleRange
}; };
EditSkillFormVersion++; EditSkillFormVersion++;
@@ -99,6 +101,7 @@ public partial class CharacterPanel
SkillGroupState.Model.DiceRollDefinition = string.Empty; SkillGroupState.Model.DiceRollDefinition = string.Empty;
SkillGroupState.Model.WildDice = IsD6 ? 1 : 0; SkillGroupState.Model.WildDice = IsD6 ? 1 : 0;
SkillGroupState.Model.AllowFumble = IsD6; SkillGroupState.Model.AllowFumble = IsD6;
SkillGroupState.Model.FumbleRange = null;
SkillGroupState.ResetValidation(); SkillGroupState.ResetValidation();
ShowCreateSkillGroupModal = true; ShowCreateSkillGroupModal = true;
} }
@@ -110,6 +113,7 @@ public partial class CharacterPanel
SkillGroupState.Model.DiceRollDefinition = skillGroup.DiceRollDefinition; SkillGroupState.Model.DiceRollDefinition = skillGroup.DiceRollDefinition;
SkillGroupState.Model.WildDice = skillGroup.WildDice; SkillGroupState.Model.WildDice = skillGroup.WildDice;
SkillGroupState.Model.AllowFumble = skillGroup.AllowFumble; SkillGroupState.Model.AllowFumble = skillGroup.AllowFumble;
SkillGroupState.Model.FumbleRange = skillGroup.FumbleRange;
SkillGroupState.ResetValidation(); SkillGroupState.ResetValidation();
ShowEditSkillGroupModal = true; ShowEditSkillGroupModal = true;
} }
@@ -155,7 +159,8 @@ public partial class CharacterPanel
SkillGroupState.Model.Name.Trim(), SkillGroupState.Model.Name.Trim(),
SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.DiceRollDefinition.Trim(),
SkillGroupState.Model.WildDice, SkillGroupState.Model.WildDice,
SkillGroupState.Model.AllowFumble)); SkillGroupState.Model.AllowFumble,
SkillGroupState.Model.FumbleRange));
CloseSkillGroupModals(); CloseSkillGroupModals();
await SkillGroupCreated.InvokeAsync(createdGroup.Id); await SkillGroupCreated.InvokeAsync(createdGroup.Id);
} }
@@ -202,7 +207,8 @@ public partial class CharacterPanel
SkillGroupState.Model.Name.Trim(), SkillGroupState.Model.Name.Trim(),
SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.DiceRollDefinition.Trim(),
SkillGroupState.Model.WildDice, SkillGroupState.Model.WildDice,
SkillGroupState.Model.AllowFumble)); SkillGroupState.Model.AllowFumble,
SkillGroupState.Model.FumbleRange));
CloseSkillGroupModals(); CloseSkillGroupModals();
await SkillGroupUpdated.InvokeAsync(updatedGroup.Id); await SkillGroupUpdated.InvokeAsync(updatedGroup.Id);
} }

View File

@@ -18,6 +18,7 @@ public partial class SkillFormModal
FormState.Model.SkillGroupId = InitialModel.SkillGroupId; FormState.Model.SkillGroupId = InitialModel.SkillGroupId;
FormState.Model.WildDice = InitialModel.WildDice; FormState.Model.WildDice = InitialModel.WildDice;
FormState.Model.AllowFumble = InitialModel.AllowFumble; FormState.Model.AllowFumble = InitialModel.AllowFumble;
FormState.Model.FumbleRange = InitialModel.FumbleRange;
FormState.ResetValidation(); FormState.ResetValidation();
AppliedFormVersion = FormVersion; AppliedFormVersion = FormVersion;
PendingNameFocus = AutoFocusName; PendingNameFocus = AutoFocusName;
@@ -66,7 +67,7 @@ public partial class SkillFormModal
SkillSummary skill; SkillSummary skill;
if (EditingSkillId.HasValue) if (EditingSkillId.HasValue)
{ {
skill = await ApiClient.RequestAsync<SkillSummary>("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId)); skill = await ApiClient.RequestAsync<SkillSummary>("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId, FormState.Model.FumbleRange));
} }
else else
{ {
@@ -76,7 +77,7 @@ public partial class SkillFormModal
return; return;
} }
skill = await ApiClient.RequestAsync<SkillSummary>("POST", $"/api/characters/{SelectedCharacterId.Value}/skills", new CreateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId)); skill = await ApiClient.RequestAsync<SkillSummary>("POST", $"/api/characters/{SelectedCharacterId.Value}/skills", new CreateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId, FormState.Model.FumbleRange));
} }
await SkillSaved.InvokeAsync(skill.Id); await SkillSaved.InvokeAsync(skill.Id);

View File

@@ -34,17 +34,17 @@ public sealed record UpdateCharacterRequest(string Name, Guid? CampaignId, strin
public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid? CampaignId, string OwnerDisplayName); public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid? CampaignId, string OwnerDisplayName);
public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null); public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null, int? FumbleRange = null);
public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null); public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null, int? FumbleRange = null);
public sealed record SkillGroupSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble); public sealed record SkillGroupSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange);
public sealed record CreateSkillGroupRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble); public sealed record CreateSkillGroupRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange = null);
public sealed record UpdateSkillGroupRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble); public sealed record UpdateSkillGroupRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange = null);
public sealed record SkillSummary(Guid Id, Guid CharacterId, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble); public sealed record SkillSummary(Guid Id, Guid CharacterId, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange);
public sealed record RollSkillRequest(string Visibility); public sealed record RollSkillRequest(string Visibility);
@@ -52,9 +52,9 @@ public sealed record RollDieResult(int Roll, bool Crit, bool Fumble, bool Wild,
public sealed record RollResult(Guid RollId, Guid CampaignId, Guid CharacterId, Guid SkillId, Guid RollerUserId, string Visibility, int Result, string Breakdown, IReadOnlyList<RollDieResult> Dice, DateTimeOffset TimestampUtc); public sealed record RollResult(Guid RollId, Guid CampaignId, Guid CharacterId, Guid SkillId, Guid RollerUserId, string Visibility, int Result, string Breakdown, IReadOnlyList<RollDieResult> Dice, DateTimeOffset TimestampUtc);
public sealed record CharacterSheetSkillGroup(Guid Id, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble); public sealed record CharacterSheetSkillGroup(Guid Id, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange);
public sealed record CharacterSheetSkill(Guid Id, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble); public sealed record CharacterSheetSkill(Guid Id, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange);
public sealed record CharacterSheet(Guid CharacterId, CharacterSheetSkillGroup[] SkillGroups, CharacterSheetSkill[] Skills); public sealed record CharacterSheet(Guid CharacterId, CharacterSheetSkillGroup[] SkillGroups, CharacterSheetSkill[] Skills);

View File

@@ -54,6 +54,7 @@ public sealed class RpgRollerDbContext : DbContext
entity.Property(x => x.DiceRollDefinition).IsRequired().HasMaxLength(128); entity.Property(x => x.DiceRollDefinition).IsRequired().HasMaxLength(128);
entity.Property(x => x.WildDice).IsRequired(); entity.Property(x => x.WildDice).IsRequired();
entity.Property(x => x.AllowFumble).IsRequired(); entity.Property(x => x.AllowFumble).IsRequired();
entity.Property(x => x.FumbleRange).IsRequired(false);
entity.HasIndex(x => x.CharacterId); entity.HasIndex(x => x.CharacterId);
entity.HasIndex(x => x.SkillGroupId); entity.HasIndex(x => x.SkillGroupId);
}); });
@@ -65,6 +66,7 @@ public sealed class RpgRollerDbContext : DbContext
entity.Property(x => x.DiceRollDefinition).IsRequired().HasMaxLength(128); entity.Property(x => x.DiceRollDefinition).IsRequired().HasMaxLength(128);
entity.Property(x => x.WildDice).IsRequired(); entity.Property(x => x.WildDice).IsRequired();
entity.Property(x => x.AllowFumble).IsRequired(); entity.Property(x => x.AllowFumble).IsRequired();
entity.Property(x => x.FumbleRange).IsRequired(false);
entity.HasIndex(x => x.CharacterId); entity.HasIndex(x => x.CharacterId);
}); });

View File

@@ -61,6 +61,7 @@ public sealed class SkillGroup
public required string DiceRollDefinition { get; set; } public required string DiceRollDefinition { get; set; }
public required int WildDice { get; set; } public required int WildDice { get; set; }
public required bool AllowFumble { get; set; } public required bool AllowFumble { get; set; }
public int? FumbleRange { get; set; }
} }
public sealed class Skill public sealed class Skill
@@ -72,6 +73,7 @@ public sealed class Skill
public required string DiceRollDefinition { get; set; } public required string DiceRollDefinition { get; set; }
public required int WildDice { get; set; } public required int WildDice { get; set; }
public required bool AllowFumble { get; set; } public required bool AllowFumble { get; set; }
public int? FumbleRange { get; set; }
} }
public sealed class RollLogEntry public sealed class RollLogEntry

View File

@@ -0,0 +1,264 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RpgRoller.Data;
#nullable disable
namespace RpgRoller.Migrations
{
[DbContext(typeof(RpgRollerDbContext))]
[Migration("20260402222501_AddRolemasterFumbleRange")]
partial class AddRolemasterFumbleRange
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("RpgRoller.Domain.Campaign", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GmUserId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Ruleset")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("Version")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("GmUserId");
b.ToTable("Campaigns");
});
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("CampaignId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("OwnerUserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("OwnerUserId");
b.ToTable("Characters");
});
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Breakdown")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid>("CampaignId")
.HasColumnType("TEXT");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("Dice")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Result")
.HasColumnType("INTEGER");
b.Property<Guid>("RollerUserId")
.HasColumnType("TEXT");
b.Property<Guid>("SkillId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("TimestampUtc")
.HasColumnType("TEXT");
b.Property<string>("Visibility")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("CharacterId");
b.HasIndex("RollerUserId");
b.HasIndex("SkillId");
b.ToTable("RollLogEntries");
});
modelBuilder.Entity("RpgRoller.Domain.Skill", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowFumble")
.HasColumnType("INTEGER");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("DiceRollDefinition")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("FumbleRange")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid?>("SkillGroupId")
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.HasIndex("SkillGroupId");
b.ToTable("Skills");
});
modelBuilder.Entity("RpgRoller.Domain.SkillGroup", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowFumble")
.HasColumnType("INTEGER");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("DiceRollDefinition")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("FumbleRange")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.ToTable("SkillGroups");
});
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ActiveCharacterId")
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Roles")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("UsernameNormalized")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UsernameNormalized")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
{
b.Property<string>("Token")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Token");
b.HasIndex("UserId");
b.ToTable("Sessions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RpgRoller.Migrations
{
/// <inheritdoc />
public partial class AddRolemasterFumbleRange : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "FumbleRange",
table: "Skills",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "FumbleRange",
table: "SkillGroups",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FumbleRange",
table: "Skills");
migrationBuilder.DropColumn(
name: "FumbleRange",
table: "SkillGroups");
}
}
}

View File

@@ -138,6 +138,9 @@ namespace RpgRoller.Migrations
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int?>("FumbleRange")
.HasColumnType("INTEGER");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(128) .HasMaxLength(128)
@@ -175,6 +178,9 @@ namespace RpgRoller.Migrations
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int?>("FumbleRange")
.HasColumnType("INTEGER");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(128) .HasMaxLength(128)

View File

@@ -496,7 +496,7 @@ public sealed class GameService : IGameService
} }
} }
public ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble) public ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillGroupSummary>.Failure("invalid_skill_group_name", "Skill group name is required."); return ServiceResult<SkillGroupSummary>.Failure("invalid_skill_group_name", "Skill group name is required.");
@@ -516,7 +516,7 @@ public sealed class GameService : IGameService
if (!CanEditCharacterLocked(user.Id, character, campaign)) if (!CanEditCharacterLocked(user.Id, character, campaign))
return ServiceResult<SkillGroupSummary>.Failure("forbidden", "Only the owner or GM can manage skill groups."); return ServiceResult<SkillGroupSummary>.Failure("forbidden", "Only the owner or GM can manage skill groups.");
var prototypeValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble); var prototypeValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange);
if (!prototypeValidation.Succeeded) if (!prototypeValidation.Succeeded)
return ServiceResult<SkillGroupSummary>.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message); return ServiceResult<SkillGroupSummary>.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message);
@@ -527,7 +527,8 @@ public sealed class GameService : IGameService
Name = name.Trim(), Name = name.Trim(),
DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression, DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression,
WildDice = prototypeValidation.Value.WildDice, WildDice = prototypeValidation.Value.WildDice,
AllowFumble = prototypeValidation.Value.AllowFumble AllowFumble = prototypeValidation.Value.AllowFumble,
FumbleRange = prototypeValidation.Value.FumbleRange
}; };
m_SkillGroupsById[group.Id] = group; m_SkillGroupsById[group.Id] = group;
@@ -538,7 +539,7 @@ public sealed class GameService : IGameService
} }
} }
public ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble) public ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillGroupSummary>.Failure("invalid_skill_group_name", "Skill group name is required."); return ServiceResult<SkillGroupSummary>.Failure("invalid_skill_group_name", "Skill group name is required.");
@@ -559,7 +560,7 @@ public sealed class GameService : IGameService
if (!CanEditCharacterLocked(user.Id, character, campaign)) if (!CanEditCharacterLocked(user.Id, character, campaign))
return ServiceResult<SkillGroupSummary>.Failure("forbidden", "Only the owner or GM can manage skill groups."); return ServiceResult<SkillGroupSummary>.Failure("forbidden", "Only the owner or GM can manage skill groups.");
var prototypeValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble); var prototypeValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange);
if (!prototypeValidation.Succeeded) if (!prototypeValidation.Succeeded)
return ServiceResult<SkillGroupSummary>.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message); return ServiceResult<SkillGroupSummary>.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message);
@@ -567,6 +568,7 @@ public sealed class GameService : IGameService
group.DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression; group.DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression;
group.WildDice = prototypeValidation.Value.WildDice; group.WildDice = prototypeValidation.Value.WildDice;
group.AllowFumble = prototypeValidation.Value.AllowFumble; group.AllowFumble = prototypeValidation.Value.AllowFumble;
group.FumbleRange = prototypeValidation.Value.FumbleRange;
TouchCharacterLocked(campaign.Id, character.Id); TouchCharacterLocked(campaign.Id, character.Id);
PersistStateLocked(); PersistStateLocked();
@@ -603,7 +605,7 @@ public sealed class GameService : IGameService
} }
} }
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null) public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required."); return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
@@ -623,7 +625,7 @@ public sealed class GameService : IGameService
if (!CanEditCharacterLocked(user.Id, character, campaign)) if (!CanEditCharacterLocked(user.Id, character, campaign))
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills."); return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
var skillValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble); var skillValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange);
if (!skillValidation.Succeeded) if (!skillValidation.Succeeded)
return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message); return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message);
@@ -639,7 +641,8 @@ public sealed class GameService : IGameService
Name = name.Trim(), Name = name.Trim(),
DiceRollDefinition = skillValidation.Value!.CanonicalExpression, DiceRollDefinition = skillValidation.Value!.CanonicalExpression,
WildDice = skillValidation.Value.WildDice, WildDice = skillValidation.Value.WildDice,
AllowFumble = skillValidation.Value.AllowFumble AllowFumble = skillValidation.Value.AllowFumble,
FumbleRange = skillValidation.Value.FumbleRange
}; };
m_SkillsById[skill.Id] = skill; m_SkillsById[skill.Id] = skill;
@@ -650,7 +653,7 @@ public sealed class GameService : IGameService
} }
} }
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null) public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required."); return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
@@ -671,7 +674,7 @@ public sealed class GameService : IGameService
if (!CanEditCharacterLocked(user.Id, character, campaign)) if (!CanEditCharacterLocked(user.Id, character, campaign))
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills."); return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
var skillValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble); var skillValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange);
if (!skillValidation.Succeeded) if (!skillValidation.Succeeded)
return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message); return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message);
@@ -683,6 +686,7 @@ public sealed class GameService : IGameService
skill.DiceRollDefinition = skillValidation.Value!.CanonicalExpression; skill.DiceRollDefinition = skillValidation.Value!.CanonicalExpression;
skill.WildDice = skillValidation.Value.WildDice; skill.WildDice = skillValidation.Value.WildDice;
skill.AllowFumble = skillValidation.Value.AllowFumble; skill.AllowFumble = skillValidation.Value.AllowFumble;
skill.FumbleRange = skillValidation.Value.FumbleRange;
skill.SkillGroupId = resolvedSkillGroupId.Value; skill.SkillGroupId = resolvedSkillGroupId.Value;
TouchCharacterLocked(campaign.Id, character.Id); TouchCharacterLocked(campaign.Id, character.Id);
@@ -876,33 +880,64 @@ public sealed class GameService : IGameService
} }
} }
private static ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble)> ValidateSkillDefinition(RulesetKind ruleset, string diceRollDefinition, int wildDice, bool allowFumble) private static ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)> ValidateSkillDefinition(RulesetKind ruleset, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange)
{ {
var expressionValidation = DiceRules.ParseExpression(ruleset, diceRollDefinition); var expressionValidation = DiceRules.ParseExpression(ruleset, diceRollDefinition);
if (!expressionValidation.Succeeded) if (!expressionValidation.Succeeded)
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble)>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message); return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
var optionsValidation = ValidateSkillOptions(ruleset, wildDice, allowFumble); var optionsValidation = ValidateSkillOptions(ruleset, expressionValidation.Value!, wildDice, allowFumble, fumbleRange);
if (!optionsValidation.Succeeded) if (!optionsValidation.Succeeded)
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble)>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message); return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble)>.Success((expressionValidation.Value!.Canonical, optionsValidation.Value!.WildDice, optionsValidation.Value.AllowFumble)); return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Success((expressionValidation.Value!.Canonical, optionsValidation.Value!.WildDice, optionsValidation.Value.AllowFumble, optionsValidation.Value.FumbleRange));
} }
private static ServiceResult<(int WildDice, bool AllowFumble)> ValidateSkillOptions(RulesetKind ruleset, int wildDice, bool allowFumble) private static ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)> ValidateSkillOptions(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange)
{ {
if (wildDice < 0 || wildDice > 50) if (wildDice < 0 || wildDice > 50)
return ServiceResult<(int WildDice, bool AllowFumble)>.Failure("invalid_wild_dice", "Wild dice must be between 0 and 50."); return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_wild_dice", "Wild dice must be between 0 and 50.");
if (ruleset == RulesetKind.D6) if (ruleset == RulesetKind.D6)
{ {
if (wildDice < 1) 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, int? FumbleRange)>.Failure("invalid_wild_dice", "D6 skills require at least one wild die.");
return ServiceResult<(int WildDice, bool AllowFumble)>.Success((wildDice, allowFumble)); if (fumbleRange.HasValue)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Fumble range is only supported for Rolemaster open-ended percentile skills.");
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Success((wildDice, allowFumble, null));
} }
return ServiceResult<(int WildDice, bool AllowFumble)>.Success((0, false)); if (ruleset == RulesetKind.Rolemaster)
{
if (wildDice != 0)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_wild_dice", "Rolemaster skills do not support wild dice.");
if (allowFumble)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_allow_fumble", "Rolemaster skills do not use the D6 allow-fumble option.");
if (expression.Kind == DiceExpressionKind.RolemasterOpenEndedPercentile)
{
if (!fumbleRange.HasValue)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Rolemaster open-ended percentile skills require a fumble range.");
if (fumbleRange < 0 || fumbleRange >= 96)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Fumble range must be between 0 and 95.");
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Success((0, false, fumbleRange));
}
if (fumbleRange.HasValue)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Fumble range is only valid for Rolemaster open-ended percentile skills.");
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Success((0, false, null));
}
if (fumbleRange.HasValue)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Fumble range is only supported for Rolemaster open-ended percentile skills.");
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Success((0, false, null));
} }
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRoll(RulesetKind ruleset, DiceExpression expression, Skill skill) private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRoll(RulesetKind ruleset, DiceExpression expression, Skill skill)
@@ -1139,22 +1174,22 @@ public sealed class GameService : IGameService
private static CharacterSheetSkillGroup ToCharacterSheetSkillGroup(SkillGroup skillGroup) private static CharacterSheetSkillGroup ToCharacterSheetSkillGroup(SkillGroup skillGroup)
{ {
return new(skillGroup.Id, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble); return new(skillGroup.Id, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble, skillGroup.FumbleRange);
} }
private static SkillGroupSummary ToSkillGroupSummary(SkillGroup skillGroup) private static SkillGroupSummary ToSkillGroupSummary(SkillGroup skillGroup)
{ {
return new(skillGroup.Id, skillGroup.CharacterId, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble); return new(skillGroup.Id, skillGroup.CharacterId, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble, skillGroup.FumbleRange);
} }
private static CharacterSheetSkill ToCharacterSheetSkill(Skill skill) private static CharacterSheetSkill ToCharacterSheetSkill(Skill skill)
{ {
return new(skill.Id, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble); return new(skill.Id, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange);
} }
private static SkillSummary ToSkillSummary(Skill skill) private static SkillSummary ToSkillSummary(Skill skill)
{ {
return new(skill.Id, skill.CharacterId, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble); return new(skill.Id, skill.CharacterId, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange);
} }
private static RollResult ToRollResult(RollLogEntry entry, IReadOnlyList<RollDieResult> dice) private static RollResult ToRollResult(RollLogEntry entry, IReadOnlyList<RollDieResult> dice)
@@ -1658,7 +1693,8 @@ public sealed class GameService : IGameService
Name = skill.Name, Name = skill.Name,
DiceRollDefinition = skill.DiceRollDefinition, DiceRollDefinition = skill.DiceRollDefinition,
WildDice = skill.WildDice, WildDice = skill.WildDice,
AllowFumble = skill.AllowFumble AllowFumble = skill.AllowFumble,
FumbleRange = skill.FumbleRange
}; };
} }
@@ -1671,7 +1707,8 @@ public sealed class GameService : IGameService
Name = skillGroup.Name, Name = skillGroup.Name,
DiceRollDefinition = skillGroup.DiceRollDefinition, DiceRollDefinition = skillGroup.DiceRollDefinition,
WildDice = skillGroup.WildDice, WildDice = skillGroup.WildDice,
AllowFumble = skillGroup.AllowFumble AllowFumble = skillGroup.AllowFumble,
FumbleRange = skillGroup.FumbleRange
}; };
} }

View File

@@ -28,11 +28,11 @@ public interface IGameService
ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId); ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId);
ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken); ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken);
ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble); ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null);
ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble); ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null);
ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId); ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId);
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null); ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null);
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null); ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null);
ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId); ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId);
ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId); ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId);

View File

@@ -900,6 +900,16 @@
}, },
"allowFumble": { "allowFumble": {
"type": "boolean" "type": "boolean"
},
"skillGroupId": {
"type": "string",
"format": "uuid",
"nullable": true
},
"fumbleRange": {
"type": "integer",
"format": "int32",
"nullable": true
} }
}, },
"required": [ "required": [
@@ -924,6 +934,16 @@
}, },
"allowFumble": { "allowFumble": {
"type": "boolean" "type": "boolean"
},
"skillGroupId": {
"type": "string",
"format": "uuid",
"nullable": true
},
"fumbleRange": {
"type": "integer",
"format": "int32",
"nullable": true
} }
}, },
"required": [ "required": [
@@ -944,6 +964,11 @@
"type": "string", "type": "string",
"format": "uuid" "format": "uuid"
}, },
"skillGroupId": {
"type": "string",
"format": "uuid",
"nullable": true
},
"name": { "name": {
"type": "string" "type": "string"
}, },
@@ -956,6 +981,11 @@
}, },
"allowFumble": { "allowFumble": {
"type": "boolean" "type": "boolean"
},
"fumbleRange": {
"type": "integer",
"format": "int32",
"nullable": true
} }
}, },
"required": [ "required": [