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

@@ -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, 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);
});
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);
});
@@ -27,13 +27,13 @@ internal static class SkillEndpoints
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);
});
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);
});

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ public partial class SkillFormModal
FormState.Model.SkillGroupId = InitialModel.SkillGroupId;
FormState.Model.WildDice = InitialModel.WildDice;
FormState.Model.AllowFumble = InitialModel.AllowFumble;
FormState.Model.FumbleRange = InitialModel.FumbleRange;
FormState.ResetValidation();
AppliedFormVersion = FormVersion;
PendingNameFocus = AutoFocusName;
@@ -66,7 +67,7 @@ public partial class SkillFormModal
SkillSummary skill;
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
{
@@ -76,7 +77,7 @@ public partial class SkillFormModal
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);

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

View File

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

View File

@@ -61,6 +61,7 @@ public sealed class SkillGroup
public required string DiceRollDefinition { get; set; }
public required int WildDice { get; set; }
public required bool AllowFumble { get; set; }
public int? FumbleRange { get; set; }
}
public sealed class Skill
@@ -72,6 +73,7 @@ public sealed class Skill
public required string DiceRollDefinition { get; set; }
public required int WildDice { get; set; }
public required bool AllowFumble { get; set; }
public int? FumbleRange { get; set; }
}
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)
.HasColumnType("TEXT");
b.Property<int?>("FumbleRange")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
@@ -175,6 +178,9 @@ namespace RpgRoller.Migrations
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("FumbleRange")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.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))
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))
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)
return ServiceResult<SkillGroupSummary>.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message);
@@ -527,7 +527,8 @@ public sealed class GameService : IGameService
Name = name.Trim(),
DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression,
WildDice = prototypeValidation.Value.WildDice,
AllowFumble = prototypeValidation.Value.AllowFumble
AllowFumble = prototypeValidation.Value.AllowFumble,
FumbleRange = prototypeValidation.Value.FumbleRange
};
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))
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))
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)
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.WildDice = prototypeValidation.Value.WildDice;
group.AllowFumble = prototypeValidation.Value.AllowFumble;
group.FumbleRange = prototypeValidation.Value.FumbleRange;
TouchCharacterLocked(campaign.Id, character.Id);
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))
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))
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)
return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message);
@@ -639,7 +641,8 @@ public sealed class GameService : IGameService
Name = name.Trim(),
DiceRollDefinition = skillValidation.Value!.CanonicalExpression,
WildDice = skillValidation.Value.WildDice,
AllowFumble = skillValidation.Value.AllowFumble
AllowFumble = skillValidation.Value.AllowFumble,
FumbleRange = skillValidation.Value.FumbleRange
};
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))
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))
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)
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.WildDice = skillValidation.Value.WildDice;
skill.AllowFumble = skillValidation.Value.AllowFumble;
skill.FumbleRange = skillValidation.Value.FumbleRange;
skill.SkillGroupId = resolvedSkillGroupId.Value;
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);
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)
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)
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 (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)
@@ -1139,22 +1174,22 @@ public sealed class GameService : IGameService
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)
{
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)
{
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)
{
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)
@@ -1658,7 +1693,8 @@ public sealed class GameService : IGameService
Name = skill.Name,
DiceRollDefinition = skill.DiceRollDefinition,
WildDice = skill.WildDice,
AllowFumble = skill.AllowFumble
AllowFumble = skill.AllowFumble,
FumbleRange = skill.FumbleRange
};
}
@@ -1671,7 +1707,8 @@ public sealed class GameService : IGameService
Name = skillGroup.Name,
DiceRollDefinition = skillGroup.DiceRollDefinition,
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<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken);
ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble);
ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, 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, int? fumbleRange = null);
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> UpdateSkill(string sessionToken, Guid skillId, 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, int? fumbleRange = null);
ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId);
ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId);