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

@@ -79,6 +79,35 @@ public sealed class CampaignApiTests : ApiTestBase
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]
public async Task SkillGroupsAndOwnerTransfer_WorkThroughApi()
{

View File

@@ -1,7 +1,9 @@
using Microsoft.Data.Sqlite;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using RpgRoller.Data;
using RpgRoller.Hosting;
@@ -125,6 +127,16 @@ public sealed class HostingCoverageTests
Assert.Contains("WildDice", 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();
rollTableInfoCommand.CommandText = "PRAGMA table_info('RollLogEntries');";
@@ -183,5 +195,130 @@ public sealed class HostingCoverageTests
rolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226160859_AddAuthorizationRolesAndCampaignDeletion';";
var rolesHistoryCount = Convert.ToInt32(rolesHistoryCommand.ExecuteScalar());
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(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);
Assert.False(negativeDndSkill.Succeeded);
var rolemasterGroup = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, rolemasterCharacter.Id, "Initiative", "2d10-15", 3, true));
Assert.Equal("2d10-15", rolemasterGroup.DiceRollDefinition);
var invalidRolemasterOptions = service.CreateSkillGroup(ownerSession, rolemasterCharacter.Id, "Invalid", "2d10-15", 3, true);
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.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(0, percentileSkill.WildDice);
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(0, openEndedSkill.WildDice);
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> ActivateCharacter(string sessionToken, Guid characterId) => 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> UpdateSkillGroup(string sessionToken, Guid skillGroupId, 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, int? fumbleRange = null) => 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> UpdateSkill(string sessionToken, Guid skillId, 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, int? fumbleRange = null) => 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<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility) => throw new NotSupportedException();