Code Cleanup

This commit is contained in:
2026-04-05 01:32:52 +02:00
parent 305999e4b7
commit 46a63f9e06
109 changed files with 939 additions and 1125 deletions

View File

@@ -167,5 +167,6 @@ SQLite migration rule:
```powershell ```powershell
pwsh ./scripts/ci-local.ps1 pwsh ./scripts/ci-local.ps1
``` ```
- `scripts/ci-local.ps1` writes coverage collector output to a unique temporary results directory outside the repo, reads coverage from there, removes that directory at the end of the run, and sweeps stray `coverage.cobertura.xml` files from `RpgRoller.Tests/TestResults`.
- Regression tests enforce payload budgets for character sheet reads, initial and incremental campaign log loads, roll mutation responses, and lazy-loaded Rolemaster roll detail payloads. - Regression tests enforce payload budgets for character sheet reads, initial and incremental campaign log loads, roll mutation responses, and lazy-loaded Rolemaster roll detail payloads.
- `RpgRoller.Tests/coverlet.runsettings` measures the full `RpgRoller` backend assembly. - `RpgRoller.Tests/coverlet.runsettings` measures the full `RpgRoller` backend assembly.

View File

@@ -48,4 +48,4 @@ public sealed class AuthApiTests : ApiTestBase
var usernames = await GetAsync<IReadOnlyList<string>>(client, "/api/users/usernames"); var usernames = await GetAsync<IReadOnlyList<string>>(client, "/api/users/usernames");
Assert.Equal(["amy", "bob", "zoe"], usernames); Assert.Equal(["amy", "bob", "zoe"], usernames);
} }
} }

View File

@@ -138,7 +138,7 @@ public sealed class CampaignApiTests : ApiTestBase
var groupedSkill = await PostAsync<CreateSkillRequest, SkillSummary>(ownerClient, $"/api/characters/{character.Id}/skills", new("Strike", "2D+1", 1, true, renamedGroup.Id)); var groupedSkill = await PostAsync<CreateSkillRequest, SkillSummary>(ownerClient, $"/api/characters/{character.Id}/skills", new("Strike", "2D+1", 1, true, renamedGroup.Id));
Assert.Equal(renamedGroup.Id, groupedSkill.SkillGroupId); Assert.Equal(renamedGroup.Id, groupedSkill.SkillGroupId);
var ungroupedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(ownerClient, $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true, null)); var ungroupedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(ownerClient, $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true));
Assert.Null(ungroupedSkill.SkillGroupId); Assert.Null(ungroupedSkill.SkillGroupId);
var groupedAgainSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(ownerClient, $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true, renamedGroup.Id)); var groupedAgainSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(ownerClient, $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true, renamedGroup.Id));
@@ -187,7 +187,7 @@ public sealed class CampaignApiTests : ApiTestBase
Assert.Contains(adminEntry.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase)); Assert.Contains(adminEntry.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
Assert.Empty(playerEntry.Roles); Assert.Empty(playerEntry.Roles);
var promotedPlayer = await PutAsync<UpdateUserRolesRequest, AdminUserSummary>(adminClient, $"/api/admin/users/{player.Id}/roles", new([ "admin" ])); var promotedPlayer = await PutAsync<UpdateUserRolesRequest, AdminUserSummary>(adminClient, $"/api/admin/users/{player.Id}/roles", new(["admin"]));
Assert.Contains(promotedPlayer.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase)); Assert.Contains(promotedPlayer.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Disposable Campaign", "d6")); var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Disposable Campaign", "d6"));
@@ -404,4 +404,4 @@ public sealed class CampaignApiTests : ApiTestBase
Assert.Equal(latestRoll.Breakdown, detail.Breakdown); Assert.Equal(latestRoll.Breakdown, detail.Breakdown);
Assert.NotEmpty(detail.Dice); Assert.NotEmpty(detail.Dice);
} }
} }

View File

@@ -18,4 +18,4 @@ public sealed class FrontendHostTests : ApiTestBase
Assert.Contains("_framework/blazor.web.js", html); Assert.Contains("_framework/blazor.web.js", html);
Assert.Contains("Connecting...", html); Assert.Contains("Connecting...", html);
} }
} }

View File

@@ -24,4 +24,4 @@ public sealed class ResponseCompressionApiTests : ApiTestBase
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
Assert.Contains("gzip", response.Content.Headers.ContentEncoding); Assert.Contains("gzip", response.Content.Headers.ContentEncoding);
} }
} }

View File

@@ -61,36 +61,28 @@ public sealed class RolemasterApiTests : ApiTestBase
var logEntry = Assert.Single(logPage.Entries); var logEntry = Assert.Single(logPage.Entries);
Assert.Equal("(05) -97 -100 -12 | open-ended low", logEntry.SummaryText); Assert.Equal("(05) -97 -100 -12 | open-ended low", logEntry.SummaryText);
var eventBadges = Assert.IsType<string[]>(logEntry.EventBadges); var eventBadges = Assert.IsType<string[]>(logEntry.EventBadges);
Assert.Collection( Assert.Collection(eventBadges, badge => Assert.Equal("rf", badge), badge => Assert.Equal("r100", badge));
eventBadges,
badge => Assert.Equal("rf", badge),
badge => Assert.Equal("r100", badge));
Assert.Equal(roll.Breakdown, detail.Breakdown); Assert.Equal(roll.Breakdown, detail.Breakdown);
Assert.Collection( Assert.Collection(detail.Dice, die =>
detail.Dice, {
die => Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
{ Assert.Equal(1, die.Sequence);
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind); Assert.Null(die.SignedContribution);
Assert.Equal(1, die.Sequence); }, die =>
Assert.Null(die.SignedContribution); {
}, Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
die => Assert.Equal(2, die.Sequence);
{ Assert.Equal(-97, die.SignedContribution);
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind); }, die =>
Assert.Equal(2, die.Sequence); {
Assert.Equal(-97, die.SignedContribution); Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
}, Assert.Equal(3, die.Sequence);
die => Assert.Equal(-100, die.SignedContribution);
{ }, die =>
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind); {
Assert.Equal(3, die.Sequence); Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
Assert.Equal(-100, die.SignedContribution); Assert.Equal(4, die.Sequence);
}, Assert.Equal(-12, die.SignedContribution);
die => });
{
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
Assert.Equal(4, die.Sequence);
Assert.Equal(-12, die.SignedContribution);
});
} }
} }

View File

@@ -97,4 +97,4 @@ public sealed class RollVisibilityApiTests : ApiTestBase
var unauthorizedWithInvalidSession = await anonymousClient.SendAsync(invalidSessionRequest); var unauthorizedWithInvalidSession = await anonymousClient.SendAsync(invalidSessionRequest);
Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedWithInvalidSession.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedWithInvalidSession.StatusCode);
} }
} }

View File

@@ -26,4 +26,4 @@ public sealed class SystemApiTests : ApiTestBase
Assert.Equal(HttpStatusCode.OK, sseResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, sseResponse.StatusCode);
Assert.Equal("text/event-stream", sseResponse.Content.Headers.ContentType?.MediaType); Assert.Equal("text/event-stream", sseResponse.Content.Headers.ContentType?.MediaType);
} }
} }

View File

@@ -1,5 +1,5 @@
using Microsoft.Data.Sqlite;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
@@ -218,12 +218,8 @@ public sealed class HostingCoverageTests
using var db = new RpgRollerDbContext(options); using var db = new RpgRollerDbContext(options);
var migrator = db.GetService<IMigrator>(); var migrator = db.GetService<IMigrator>();
var charactersScript = migrator.GenerateScript( var charactersScript = migrator.GenerateScript("20260226131003_AddSkillGroupPrototypes", "20260226160859_AddAuthorizationRolesAndCampaignDeletion");
fromMigration: "20260226131003_AddSkillGroupPrototypes", var rolesScript = migrator.GenerateScript("20260226160859_AddAuthorizationRolesAndCampaignDeletion", "20260226170000_AddAuthorizationRoles");
toMigration: "20260226160859_AddAuthorizationRolesAndCampaignDeletion");
var rolesScript = migrator.GenerateScript(
fromMigration: "20260226160859_AddAuthorizationRolesAndCampaignDeletion",
toMigration: "20260226170000_AddAuthorizationRoles");
Assert.Contains("""CREATE TABLE "ef_temp_Characters" (""", charactersScript); Assert.Contains("""CREATE TABLE "ef_temp_Characters" (""", charactersScript);
Assert.DoesNotContain("""ALTER TABLE "Users" ADD "Roles" TEXT NOT NULL DEFAULT 'admin';""", charactersScript); Assert.DoesNotContain("""ALTER TABLE "Users" ADD "Roles" TEXT NOT NULL DEFAULT 'admin';""", charactersScript);
@@ -359,7 +355,7 @@ public sealed class HostingCoverageTests
{ {
var sourceDbPath = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "RpgRoller", "App_Data", "rpgroller.development.db"); 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"); var copiedDbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-dev-copy-{Guid.NewGuid():N}.db");
File.Copy(Path.GetFullPath(sourceDbPath), copiedDbPath, overwrite: true); File.Copy(Path.GetFullPath(sourceDbPath), copiedDbPath, true);
Guid skillId; Guid skillId;
Guid ownerUserId; Guid ownerUserId;
@@ -427,10 +423,7 @@ public sealed class HostingCoverageTests
builder.Logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning); builder.Logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning);
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning); builder.Logging.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning);
builder.Logging.AddFilter("Microsoft.Hosting", LogLevel.Warning); builder.Logging.AddFilter("Microsoft.Hosting", LogLevel.Warning);
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?> builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["ConnectionStrings:RpgRoller"] = $"Data Source={copiedDbPath}" });
{
["ConnectionStrings:RpgRoller"] = $"Data Source={copiedDbPath}"
});
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment); builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
using var app = builder.Build(); using var app = builder.Build();
@@ -450,9 +443,9 @@ public sealed class HostingCoverageTests
using var countsAfterCommand = verifyConnection.CreateCommand(); using var countsAfterCommand = verifyConnection.CreateCommand();
countsAfterCommand.CommandText = """ countsAfterCommand.CommandText = """
SELECT (SELECT COUNT(*) FROM Campaigns), SELECT (SELECT COUNT(*) FROM Campaigns),
(SELECT COUNT(*) FROM Skills); (SELECT COUNT(*) FROM Skills);
"""; """;
using var countsAfterReader = countsAfterCommand.ExecuteReader(); using var countsAfterReader = countsAfterCommand.ExecuteReader();
Assert.True(countsAfterReader.Read()); Assert.True(countsAfterReader.Read());
Assert.Equal(campaignCountBefore, countsAfterReader.GetInt32(0)); Assert.Equal(campaignCountBefore, countsAfterReader.GetInt32(0));
@@ -481,4 +474,4 @@ public sealed class HostingCoverageTests
var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar()); var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar());
Assert.Equal(1, authorizationRolesHistoryCount); Assert.Equal(1, authorizationRolesHistoryCount);
} }
} }

View File

@@ -1,5 +1,4 @@
using System.Text.Json; using System.Text.Json;
using RpgRoller.Contracts;
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
@@ -133,7 +132,7 @@ public sealed class PayloadBudgetTests
[Fact] [Fact]
public void RolemasterRollDetailPayload_StaysWithinBudget_AndRolemasterMetadataRemainsLazy() public void RolemasterRollDetailPayload_StaysWithinBudget_AndRolemasterMetadataRemainsLazy()
{ {
using var harness = ServiceTestSupport.CreateHarness([96, 100, 100, 100, 100, 97, 12]); using var harness = ServiceTestSupport.CreateHarness(96, 100, 100, 100, 100, 97, 12);
var service = harness.Service; var service = harness.Service;
service.Register("gm-rm-detail-budget", "Password123", "GM"); service.Register("gm-rm-detail-budget", "Password123", "GM");
@@ -192,4 +191,4 @@ public sealed class PayloadBudgetTests
} }
private static readonly JsonSerializerOptions SerializerOptions = RpgRollerJson.CreateSerializerOptions(); private static readonly JsonSerializerOptions SerializerOptions = RpgRollerJson.CreateSerializerOptions();
} }

View File

@@ -56,4 +56,4 @@ public sealed class DiceRulesTests
Assert.Equal("rolemaster", DiceRules.ToRulesetId(RulesetKind.Rolemaster)); Assert.Equal("rolemaster", DiceRules.ToRulesetId(RulesetKind.Rolemaster));
Assert.Throws<ArgumentOutOfRangeException>(() => DiceRules.ToRulesetId((RulesetKind)99)); Assert.Throws<ArgumentOutOfRangeException>(() => DiceRules.ToRulesetId((RulesetKind)99));
} }
} }

View File

@@ -1,5 +1,3 @@
using RpgRoller.Domain;
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
public sealed class ServiceAdminAndCampaignDeletionTests public sealed class ServiceAdminAndCampaignDeletionTests
@@ -139,4 +137,4 @@ public sealed class ServiceAdminAndCampaignDeletionTests
Assert.DoesNotContain(db.Characters, character => character.Id == gmCharacterOutsideOwnedCampaign.Id); Assert.DoesNotContain(db.Characters, character => character.Id == gmCharacterOutsideOwnedCampaign.Id);
} }
} }

View File

@@ -74,4 +74,4 @@ public sealed class ServiceAuthTests
var usernames = ServiceTestSupport.GetValue(service.GetUsernames(session)); var usernames = ServiceTestSupport.GetValue(service.GetUsernames(session));
Assert.Equal(["amy", "bob", "zoe"], usernames); Assert.Equal(["amy", "bob", "zoe"], usernames);
} }
} }

View File

@@ -163,4 +163,4 @@ public sealed class ServiceCampaignTests
Assert.Equal(updatedCharacterVersion, Assert.Single(afterRoll.CharacterVersions, version => version.CharacterId == character.Id).Version); Assert.Equal(updatedCharacterVersion, Assert.Single(afterRoll.CharacterVersions, version => version.CharacterId == character.Id).Version);
Assert.True(afterRoll.LogVersion > afterSkillCreate.LogVersion); Assert.True(afterRoll.LogVersion > afterSkillCreate.LogVersion);
} }
} }

View File

@@ -64,4 +64,4 @@ public sealed class ServiceHelperExtractionTests
Assert.False(invalidRolemaster.Succeeded); Assert.False(invalidRolemaster.Succeeded);
Assert.Equal("invalid_fumble_range", invalidRolemaster.Error!.Code); Assert.Equal("invalid_fumble_range", invalidRolemaster.Error!.Code);
} }
} }

View File

@@ -119,4 +119,4 @@ public sealed class ServicePersistenceTests
var reloadedSkill = Assert.Single(reloadedSheet.Skills, current => current.Id == skill.Id); var reloadedSkill = Assert.Single(reloadedSheet.Skills, current => current.Id == skill.Id);
Assert.Equal(3, reloadedSkill.FumbleRange); Assert.Equal(3, reloadedSkill.FumbleRange);
} }
} }

View File

@@ -20,22 +20,19 @@ public sealed class ServiceRolemasterRollTests
Assert.Equal(65, roll.Result); Assert.Equal(65, roll.Result);
Assert.Equal("7+10+48=65", roll.Breakdown); Assert.Equal("7+10+48=65", roll.Breakdown);
Assert.Equal("7 + 10 | rolemaster", Assert.Single(logPage.Entries).SummaryText); Assert.Equal("7 + 10 | rolemaster", Assert.Single(logPage.Entries).SummaryText);
Assert.Collection( Assert.Collection(roll.Dice, die =>
roll.Dice, {
die => Assert.Equal(7, die.Roll);
{ Assert.Equal(1, die.Sequence);
Assert.Equal(7, die.Roll); Assert.Equal(RollDieKinds.RolemasterStandard, die.Kind);
Assert.Equal(1, die.Sequence); Assert.Equal(7, die.SignedContribution);
Assert.Equal(RollDieKinds.RolemasterStandard, die.Kind); }, die =>
Assert.Equal(7, die.SignedContribution); {
}, Assert.Equal(10, die.Roll);
die => Assert.Equal(2, die.Sequence);
{ Assert.Equal(RollDieKinds.RolemasterStandard, die.Kind);
Assert.Equal(10, die.Roll); Assert.Equal(10, die.SignedContribution);
Assert.Equal(2, die.Sequence); });
Assert.Equal(RollDieKinds.RolemasterStandard, die.Kind);
Assert.Equal(10, die.SignedContribution);
});
} }
[Fact] [Fact]
@@ -86,32 +83,28 @@ public sealed class ServiceRolemasterRollTests
Assert.Equal("97 + 96 + 45 | open-ended high", Assert.Single(logPage.Entries).SummaryText); Assert.Equal("97 + 96 + 45 | open-ended high", Assert.Single(logPage.Entries).SummaryText);
Assert.Null(Assert.Single(logPage.Entries).EventBadges); Assert.Null(Assert.Single(logPage.Entries).EventBadges);
Assert.Equal(roll.Breakdown, detail.Breakdown); Assert.Equal(roll.Breakdown, detail.Breakdown);
Assert.Collection( Assert.Collection(detail.Dice, die =>
detail.Dice, {
die => Assert.Equal(97, die.Roll);
{ Assert.Equal(1, die.Sequence);
Assert.Equal(97, die.Roll); Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
Assert.Equal(1, die.Sequence); Assert.Equal(97, die.SignedContribution);
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind); Assert.False(die.Added);
Assert.Equal(97, die.SignedContribution); }, die =>
Assert.False(die.Added); {
}, Assert.Equal(96, die.Roll);
die => Assert.Equal(2, die.Sequence);
{ Assert.Equal(RollDieKinds.RolemasterOpenEndedHigh, die.Kind);
Assert.Equal(96, die.Roll); Assert.Equal(96, die.SignedContribution);
Assert.Equal(2, die.Sequence); Assert.True(die.Added);
Assert.Equal(RollDieKinds.RolemasterOpenEndedHigh, die.Kind); }, die =>
Assert.Equal(96, die.SignedContribution); {
Assert.True(die.Added); Assert.Equal(45, die.Roll);
}, Assert.Equal(3, die.Sequence);
die => Assert.Equal(RollDieKinds.RolemasterOpenEndedHigh, die.Kind);
{ Assert.Equal(45, die.SignedContribution);
Assert.Equal(45, die.Roll); Assert.True(die.Added);
Assert.Equal(3, die.Sequence); });
Assert.Equal(RollDieKinds.RolemasterOpenEndedHigh, die.Kind);
Assert.Equal(45, die.SignedContribution);
Assert.True(die.Added);
});
} }
[Fact] [Fact]
@@ -134,40 +127,32 @@ public sealed class ServiceRolemasterRollTests
var logEntry = Assert.Single(logPage.Entries); var logEntry = Assert.Single(logPage.Entries);
Assert.Equal("(05) -97 -100 -12 | open-ended low", logEntry.SummaryText); Assert.Equal("(05) -97 -100 -12 | open-ended low", logEntry.SummaryText);
var lowEventBadges = Assert.IsType<string[]>(logEntry.EventBadges); var lowEventBadges = Assert.IsType<string[]>(logEntry.EventBadges);
Assert.Collection( Assert.Collection(lowEventBadges, badge => Assert.Equal("rf", badge), badge => Assert.Equal("r100", badge));
lowEventBadges, Assert.Collection(roll.Dice, die =>
badge => Assert.Equal("rf", badge), {
badge => Assert.Equal("r100", badge)); Assert.Equal(5, die.Roll);
Assert.Collection( Assert.Equal(1, die.Sequence);
roll.Dice, Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
die => Assert.Null(die.SignedContribution);
{ }, die =>
Assert.Equal(5, die.Roll); {
Assert.Equal(1, die.Sequence); Assert.Equal(97, die.Roll);
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind); Assert.Equal(2, die.Sequence);
Assert.Null(die.SignedContribution); Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
}, Assert.Equal(-97, die.SignedContribution);
die => }, die =>
{ {
Assert.Equal(97, die.Roll); Assert.Equal(100, die.Roll);
Assert.Equal(2, die.Sequence); Assert.Equal(3, die.Sequence);
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind); Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
Assert.Equal(-97, die.SignedContribution); Assert.Equal(-100, die.SignedContribution);
}, }, die =>
die => {
{ Assert.Equal(12, die.Roll);
Assert.Equal(100, die.Roll); Assert.Equal(4, die.Sequence);
Assert.Equal(3, die.Sequence); Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind); Assert.Equal(-12, die.SignedContribution);
Assert.Equal(-100, die.SignedContribution); });
},
die =>
{
Assert.Equal(12, die.Roll);
Assert.Equal(4, die.Sequence);
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
Assert.Equal(-12, die.SignedContribution);
});
} }
[Fact] [Fact]
@@ -189,4 +174,4 @@ public sealed class ServiceRolemasterRollTests
Assert.Equal("r66", badge); Assert.Equal("r66", badge);
Assert.Equal("66 | rolemaster", logEntry.SummaryText); Assert.Equal("66 | rolemaster", logEntry.SummaryText);
} }
} }

View File

@@ -1,67 +1,7 @@
using RpgRoller.Contracts;
using RpgRoller.Domain;
using RpgRoller.Services;
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
public sealed class ServiceRollHelperTests public sealed class ServiceRollHelperTests
{ {
[Fact]
public void RollBreakdownFormatter_FormatsStandardAndRolemasterOpenEndedBreakdowns()
{
Assert.Equal("4+5+2=11", RollBreakdownFormatter.BuildBreakdown([4, 5], 2, 11));
Assert.Equal("0=0", RollBreakdownFormatter.BuildBreakdown([], 0, 0));
Assert.Equal("97+96+45+85=323", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(97, [96, 45], false, 85, 323));
Assert.Equal("(05) -97 -100 -12 +85 = -124", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(5, [97, 100, 12], true, 85, -124));
Assert.Equal("05", RollBreakdownFormatter.FormatRolemasterTriggerRoll(5));
}
[Fact]
public void CampaignLogSummaryBuilder_ExtractsExpressionsAndBuildsBadgesAndSummaries()
{
var d6Dice = new[]
{
new RollDieResult(6, true, false, true, false, false),
new RollDieResult(1, false, true, true, false, false)
};
var rolemasterDice = new[]
{
new RollDieResult(5, false, false, false, false, false, 1, RollDieKinds.RolemasterOpenEndedInitial, null),
new RollDieResult(97, false, false, false, false, false, 2, RollDieKinds.RolemasterOpenEndedLowSubtract, -97),
new RollDieResult(100, false, false, false, false, false, 3, RollDieKinds.RolemasterOpenEndedLowSubtract, -100)
};
Assert.Equal("1d20+5", CampaignLogSummaryBuilder.ExtractCustomRollExpression("1d20+5 => 20+5=25", " => "));
Assert.Null(CampaignLogSummaryBuilder.ExtractCustomRollExpression("20+5=25", " => "));
Assert.Equal("6, 1", CampaignLogSummaryBuilder.BuildCompactLogSummary(d6Dice));
Assert.Equal("(05) -97 -100 | open-ended low", CampaignLogSummaryBuilder.BuildCompactLogSummary(rolemasterDice));
Assert.Equal("No detail available.", CampaignLogSummaryBuilder.BuildCompactLogSummary([]));
Assert.Equal(["w6", "w1"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.D6, null, d6Dice)));
Assert.Equal(["n20"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Dnd5e, "1d20+5", [new RollDieResult(20, false, false, false, false, false)])));
Assert.Equal(["rf", "r100"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Rolemaster, "d100!+85", rolemasterDice)));
}
[Fact]
public void RollEngine_DelegatesToRulesetSpecificEngines()
{
var engine = new RollEngine(
new StandardRollEngine(new FixedDiceRoller([7, 10])),
new D6RollEngine(new FixedDiceRoller([6, 4, 2])),
new RolemasterRollEngine(new FixedDiceRoller([97, 96, 45])));
var d6Roll = engine.Roll(RulesetKind.D6, new DiceExpression(2, 6, 1, "2D+1"), 1, true, null);
Assert.Equal(13, d6Roll.Total);
Assert.Equal("6+4+2+1=13", d6Roll.Breakdown);
var standardRoll = engine.Roll(RulesetKind.Dnd5e, new DiceExpression(2, 10, 3, "2d10+3"), 0, false, null);
Assert.Equal(20, standardRoll.Total);
Assert.Equal("7+10+3=20", standardRoll.Breakdown);
var rolemasterRoll = engine.Roll(RulesetKind.Rolemaster, new DiceExpression(1, 100, 85, "d100!+85", DiceExpressionKind.RolemasterOpenEndedPercentile), 0, false, 5);
Assert.Equal(323, rolemasterRoll.Total);
Assert.Equal("97+96+45+85=323", rolemasterRoll.Breakdown);
}
private sealed class FixedDiceRoller : IDiceRoller private sealed class FixedDiceRoller : IDiceRoller
{ {
public FixedDiceRoller(IEnumerable<int> values) public FixedDiceRoller(IEnumerable<int> values)
@@ -77,4 +17,48 @@ public sealed class ServiceRollHelperTests
private readonly Queue<int> m_Values; private readonly Queue<int> m_Values;
} }
}
[Fact]
public void RollBreakdownFormatter_FormatsStandardAndRolemasterOpenEndedBreakdowns()
{
Assert.Equal("4+5+2=11", RollBreakdownFormatter.BuildBreakdown([4, 5], 2, 11));
Assert.Equal("0=0", RollBreakdownFormatter.BuildBreakdown([], 0, 0));
Assert.Equal("97+96+45+85=323", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(97, [96, 45], false, 85, 323));
Assert.Equal("(05) -97 -100 -12 +85 = -124", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(5, [97, 100, 12], true, 85, -124));
Assert.Equal("05", RollBreakdownFormatter.FormatRolemasterTriggerRoll(5));
}
[Fact]
public void CampaignLogSummaryBuilder_ExtractsExpressionsAndBuildsBadgesAndSummaries()
{
var d6Dice = new[] { new RollDieResult(6, true, false, true, false, false), new RollDieResult(1, false, true, true, false, false) };
var rolemasterDice = new[] { new RollDieResult(5, false, false, false, false, false, 1, RollDieKinds.RolemasterOpenEndedInitial), new RollDieResult(97, false, false, false, false, false, 2, RollDieKinds.RolemasterOpenEndedLowSubtract, -97), new RollDieResult(100, false, false, false, false, false, 3, RollDieKinds.RolemasterOpenEndedLowSubtract, -100) };
Assert.Equal("1d20+5", CampaignLogSummaryBuilder.ExtractCustomRollExpression("1d20+5 => 20+5=25", " => "));
Assert.Null(CampaignLogSummaryBuilder.ExtractCustomRollExpression("20+5=25", " => "));
Assert.Equal("6, 1", CampaignLogSummaryBuilder.BuildCompactLogSummary(d6Dice));
Assert.Equal("(05) -97 -100 | open-ended low", CampaignLogSummaryBuilder.BuildCompactLogSummary(rolemasterDice));
Assert.Equal("No detail available.", CampaignLogSummaryBuilder.BuildCompactLogSummary([]));
Assert.Equal(["w6", "w1"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.D6, null, d6Dice)));
Assert.Equal(["n20"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Dnd5e, "1d20+5", [new(20, false, false, false, false, false)])));
Assert.Equal(["rf", "r100"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Rolemaster, "d100!+85", rolemasterDice)));
}
[Fact]
public void RollEngine_DelegatesToRulesetSpecificEngines()
{
var engine = new RollEngine(new(new FixedDiceRoller([7, 10])), new(new FixedDiceRoller([6, 4, 2])), new(new FixedDiceRoller([97, 96, 45])));
var d6Roll = engine.Roll(RulesetKind.D6, new(2, 6, 1, "2D+1"), 1, true, null);
Assert.Equal(13, d6Roll.Total);
Assert.Equal("6+4+2+1=13", d6Roll.Breakdown);
var standardRoll = engine.Roll(RulesetKind.Dnd5e, new(2, 10, 3, "2d10+3"), 0, false, null);
Assert.Equal(20, standardRoll.Total);
Assert.Equal("7+10+3=20", standardRoll.Breakdown);
var rolemasterRoll = engine.Roll(RulesetKind.Rolemaster, new(1, 100, 85, "d100!+85", DiceExpressionKind.RolemasterOpenEndedPercentile), 0, false, 5);
Assert.Equal(323, rolemasterRoll.Total);
Assert.Equal("97+96+45+85=323", rolemasterRoll.Breakdown);
}
}

View File

@@ -1,7 +1,3 @@
using RpgRoller.Contracts;
using RpgRoller.Domain;
using RpgRoller.Services;
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
public sealed class ServiceSharedHelperTests public sealed class ServiceSharedHelperTests
@@ -13,7 +9,7 @@ public sealed class ServiceSharedHelperTests
var characterId = Guid.NewGuid(); var characterId = Guid.NewGuid();
var store = new GameStateStore(); var store = new GameStateStore();
store.CampaignsById[campaignId] = new Campaign store.CampaignsById[campaignId] = new()
{ {
Id = campaignId, Id = campaignId,
GmUserId = Guid.NewGuid(), GmUserId = Guid.NewGuid(),
@@ -21,7 +17,7 @@ public sealed class ServiceSharedHelperTests
Ruleset = RulesetKind.D6, Ruleset = RulesetKind.D6,
Version = 1 Version = 1
}; };
store.CharactersById[characterId] = new Character store.CharactersById[characterId] = new()
{ {
Id = characterId, Id = characterId,
OwnerUserId = Guid.NewGuid(), OwnerUserId = Guid.NewGuid(),
@@ -65,7 +61,7 @@ public sealed class ServiceSharedHelperTests
var campaignId = Guid.NewGuid(); var campaignId = Guid.NewGuid();
var store = new GameStateStore(); var store = new GameStateStore();
store.UsersById[adminId] = new UserAccount store.UsersById[adminId] = new()
{ {
Id = adminId, Id = adminId,
Username = "admin", Username = "admin",
@@ -74,7 +70,7 @@ public sealed class ServiceSharedHelperTests
DisplayName = "Admin", DisplayName = "Admin",
Roles = UserRoles.Admin Roles = UserRoles.Admin
}; };
store.UsersById[gmId] = new UserAccount store.UsersById[gmId] = new()
{ {
Id = gmId, Id = gmId,
Username = "gm", Username = "gm",
@@ -83,7 +79,7 @@ public sealed class ServiceSharedHelperTests
DisplayName = "GM", DisplayName = "GM",
Roles = string.Empty Roles = string.Empty
}; };
store.UsersById[playerId] = new UserAccount store.UsersById[playerId] = new()
{ {
Id = playerId, Id = playerId,
Username = "player", Username = "player",
@@ -92,7 +88,7 @@ public sealed class ServiceSharedHelperTests
DisplayName = "Player", DisplayName = "Player",
Roles = string.Empty Roles = string.Empty
}; };
store.UsersById[outsiderId] = new UserAccount store.UsersById[outsiderId] = new()
{ {
Id = outsiderId, Id = outsiderId,
Username = "outsider", Username = "outsider",
@@ -112,7 +108,7 @@ public sealed class ServiceSharedHelperTests
}; };
store.CampaignsById[campaignId] = campaign; store.CampaignsById[campaignId] = campaign;
var playerCharacterId = Guid.NewGuid(); var playerCharacterId = Guid.NewGuid();
store.CharactersById[playerCharacterId] = new Character store.CharactersById[playerCharacterId] = new()
{ {
Id = playerCharacterId, Id = playerCharacterId,
OwnerUserId = playerId, OwnerUserId = playerId,
@@ -171,7 +167,7 @@ public sealed class ServiceSharedHelperTests
var campaignId = Guid.NewGuid(); var campaignId = Guid.NewGuid();
var store = new GameStateStore(); var store = new GameStateStore();
store.UsersById[userId] = new UserAccount store.UsersById[userId] = new()
{ {
Id = userId, Id = userId,
Username = "user", Username = "user",
@@ -180,7 +176,7 @@ public sealed class ServiceSharedHelperTests
DisplayName = "User", DisplayName = "User",
Roles = string.Empty Roles = string.Empty
}; };
store.UsersById[otherUserId] = new UserAccount store.UsersById[otherUserId] = new()
{ {
Id = otherUserId, Id = otherUserId,
Username = "other", Username = "other",
@@ -189,13 +185,13 @@ public sealed class ServiceSharedHelperTests
DisplayName = "Other", DisplayName = "Other",
Roles = string.Empty Roles = string.Empty
}; };
store.SessionsByToken["valid"] = new UserSession store.SessionsByToken["valid"] = new()
{ {
Token = "valid", Token = "valid",
UserId = userId, UserId = userId,
CreatedAtUtc = DateTimeOffset.UtcNow CreatedAtUtc = DateTimeOffset.UtcNow
}; };
store.CampaignsById[campaignId] = new Campaign store.CampaignsById[campaignId] = new()
{ {
Id = campaignId, Id = campaignId,
GmUserId = otherUserId, GmUserId = otherUserId,
@@ -267,7 +263,7 @@ public sealed class ServiceSharedHelperTests
var rollId = Guid.NewGuid(); var rollId = Guid.NewGuid();
var store = new GameStateStore(); var store = new GameStateStore();
store.UsersById[gmId] = new UserAccount store.UsersById[gmId] = new()
{ {
Id = gmId, Id = gmId,
Username = "gm", Username = "gm",
@@ -276,7 +272,7 @@ public sealed class ServiceSharedHelperTests
DisplayName = "GM", DisplayName = "GM",
Roles = UserRoles.Admin Roles = UserRoles.Admin
}; };
store.UsersById[ownerId] = new UserAccount store.UsersById[ownerId] = new()
{ {
Id = ownerId, Id = ownerId,
Username = "owner", Username = "owner",
@@ -285,7 +281,7 @@ public sealed class ServiceSharedHelperTests
DisplayName = "Owner", DisplayName = "Owner",
Roles = string.Empty Roles = string.Empty
}; };
store.UsersById[blankOwnerId] = new UserAccount store.UsersById[blankOwnerId] = new()
{ {
Id = blankOwnerId, Id = blankOwnerId,
Username = "blank", Username = "blank",
@@ -294,7 +290,7 @@ public sealed class ServiceSharedHelperTests
DisplayName = "", DisplayName = "",
Roles = string.Empty Roles = string.Empty
}; };
store.CampaignsById[campaignId] = new Campaign store.CampaignsById[campaignId] = new()
{ {
Id = campaignId, Id = campaignId,
GmUserId = gmId, GmUserId = gmId,
@@ -302,14 +298,14 @@ public sealed class ServiceSharedHelperTests
Ruleset = RulesetKind.Rolemaster, Ruleset = RulesetKind.Rolemaster,
Version = 1 Version = 1
}; };
store.CharactersById[characterId] = new Character store.CharactersById[characterId] = new()
{ {
Id = characterId, Id = characterId,
OwnerUserId = ownerId, OwnerUserId = ownerId,
CampaignId = campaignId, CampaignId = campaignId,
Name = "Scout" Name = "Scout"
}; };
store.SkillGroupsById[skillGroupId] = new SkillGroup store.SkillGroupsById[skillGroupId] = new()
{ {
Id = skillGroupId, Id = skillGroupId,
CharacterId = characterId, CharacterId = characterId,
@@ -319,7 +315,7 @@ public sealed class ServiceSharedHelperTests
AllowFumble = false, AllowFumble = false,
FumbleRange = 5 FumbleRange = 5
}; };
store.SkillsById[skillId] = new Skill store.SkillsById[skillId] = new()
{ {
Id = skillId, Id = skillId,
CharacterId = characterId, CharacterId = characterId,
@@ -384,4 +380,4 @@ public sealed class ServiceSharedHelperTests
Assert.Equal("fallback", GameDtoMapper.ResolveOwnerDisplayName(store, blankOwnerId, "fallback")); Assert.Equal("fallback", GameDtoMapper.ResolveOwnerDisplayName(store, blankOwnerId, "fallback"));
Assert.Equal("fallback", GameDtoMapper.ResolveOwnerDisplayName(store, Guid.NewGuid(), "fallback")); Assert.Equal("fallback", GameDtoMapper.ResolveOwnerDisplayName(store, Guid.NewGuid(), "fallback"));
} }
} }

View File

@@ -36,7 +36,7 @@ public sealed class ServiceSkillGroupAndOwnershipTests
var otherGroup = ServiceTestSupport.GetValue(service.CreateSkillGroup(otherSession, otherCharacter.Id, "Other Group", "2D+1", 1, true)); var otherGroup = ServiceTestSupport.GetValue(service.CreateSkillGroup(otherSession, otherCharacter.Id, "Other Group", "2D+1", 1, true));
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "Strike", "2D+1", 1, true, otherGroup.Id).Succeeded); Assert.False(service.UpdateSkill(ownerSession, skill.Id, "Strike", "2D+1", 1, true, otherGroup.Id).Succeeded);
var ungroupedSkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, skill.Id, "Strike", "2D+1", 1, true, null)); var ungroupedSkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, skill.Id, "Strike", "2D+1", 1, true));
Assert.Null(ungroupedSkill.SkillGroupId); Assert.Null(ungroupedSkill.SkillGroupId);
var regroupedSkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, skill.Id, "Strike", "2D+1", 1, true, renamedGroup.Id)); var regroupedSkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, skill.Id, "Strike", "2D+1", 1, true, renamedGroup.Id));
@@ -133,7 +133,7 @@ public sealed class ServiceSkillGroupAndOwnershipTests
var adminTwo = service.GetUserBySession(adminTwoSession); var adminTwo = service.GetUserBySession(adminTwoSession);
Assert.NotNull(adminTwo); Assert.NotNull(adminTwo);
_ = ServiceTestSupport.GetValue(service.UpdateUserRoles(gmSession, adminTwo!.Id, [ "admin" ])); _ = ServiceTestSupport.GetValue(service.UpdateUserRoles(gmSession, adminTwo!.Id, ["admin"]));
var adminUnlink = ServiceTestSupport.GetValue(service.UpdateCharacter(adminTwoSession, character.Id, "Admin Unlink", null)); var adminUnlink = ServiceTestSupport.GetValue(service.UpdateCharacter(adminTwoSession, character.Id, "Admin Unlink", null));
Assert.Null(adminUnlink.CampaignId); Assert.Null(adminUnlink.CampaignId);
@@ -225,4 +225,4 @@ public sealed class ServiceSkillGroupAndOwnershipTests
Assert.False(openEndedSkill.AllowFumble); Assert.False(openEndedSkill.AllowFumble);
Assert.Equal(5, openEndedSkill.FumbleRange); Assert.Equal(5, openEndedSkill.FumbleRange);
} }
} }

View File

@@ -264,4 +264,4 @@ public sealed class ServiceSkillRollTests
var log = ServiceTestSupport.GetValue(service.GetCampaignLog(ownerSession, campaign.Id)); var log = ServiceTestSupport.GetValue(service.GetCampaignLog(ownerSession, campaign.Id));
Assert.Equal("Custom roll", Assert.Single(log).SkillName); Assert.Equal("Custom roll", Assert.Single(log).SkillName);
} }
} }

View File

@@ -32,11 +32,48 @@ public sealed class ServiceStateInfrastructureTests
Roles = "admin", Roles = "admin",
ActiveCharacterId = Guid.NewGuid() ActiveCharacterId = Guid.NewGuid()
}; };
var session = new UserSession { Token = "token", UserId = user.Id, CreatedAtUtc = DateTimeOffset.UtcNow }; var session = new UserSession
var campaign = new Campaign { Id = Guid.NewGuid(), GmUserId = user.Id, Name = "Main", Ruleset = RulesetKind.D6, Version = 3 }; {
var character = new Character { Id = Guid.NewGuid(), OwnerUserId = user.Id, CampaignId = campaign.Id, Name = "Hero" }; Token = "token",
var skillGroup = new SkillGroup { Id = Guid.NewGuid(), CharacterId = character.Id, Name = "Group", DiceRollDefinition = "2D+1", WildDice = 1, AllowFumble = true, FumbleRange = null }; UserId = user.Id,
var skill = new Skill { Id = Guid.NewGuid(), CharacterId = character.Id, SkillGroupId = skillGroup.Id, Name = "Skill", DiceRollDefinition = "2D+2", WildDice = 1, AllowFumble = true, FumbleRange = null }; CreatedAtUtc = DateTimeOffset.UtcNow
};
var campaign = new Campaign
{
Id = Guid.NewGuid(),
GmUserId = user.Id,
Name = "Main",
Ruleset = RulesetKind.D6,
Version = 3
};
var character = new Character
{
Id = Guid.NewGuid(),
OwnerUserId = user.Id,
CampaignId = campaign.Id,
Name = "Hero"
};
var skillGroup = new SkillGroup
{
Id = Guid.NewGuid(),
CharacterId = character.Id,
Name = "Group",
DiceRollDefinition = "2D+1",
WildDice = 1,
AllowFumble = true,
FumbleRange = null
};
var skill = new Skill
{
Id = Guid.NewGuid(),
CharacterId = character.Id,
SkillGroupId = skillGroup.Id,
Name = "Skill",
DiceRollDefinition = "2D+2",
WildDice = 1,
AllowFumble = true,
FumbleRange = null
};
var logEntry = new RollLogEntry var logEntry = new RollLogEntry
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
@@ -59,4 +96,4 @@ public sealed class ServiceStateInfrastructureTests
Assert.NotSame(skill, GameStateCloneFactory.CloneSkill(skill)); Assert.NotSame(skill, GameStateCloneFactory.CloneSkill(skill));
Assert.NotSame(logEntry, GameStateCloneFactory.CloneRollLogEntry(logEntry)); Assert.NotSame(logEntry, GameStateCloneFactory.CloneRollLogEntry(logEntry));
} }
} }

View File

@@ -1,12 +1,182 @@
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using RpgRoller.Components; using RpgRoller.Components;
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
public sealed class WorkspaceQueryServiceTests public sealed class WorkspaceQueryServiceTests
{ {
private sealed class StubGameService : IGameService
{
public IReadOnlyList<RulesetDefinition> GetRulesets()
{
throw new NotSupportedException();
}
public ServiceResult<UserSummary> Register(string username, string password, string displayName)
{
throw new NotSupportedException();
}
public ServiceResult<(UserSummary User, string SessionToken)> Login(string username, string password)
{
throw new NotSupportedException();
}
public void Logout(string sessionToken)
{
throw new NotSupportedException();
}
public UserSummary? GetUserBySession(string sessionToken)
{
throw new NotSupportedException();
}
public ServiceResult<MeResponse> GetMe(string sessionToken)
{
return GetMeHandler(sessionToken);
}
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId)
{
throw new NotSupportedException();
}
public ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken)
{
return GetCampaignsHandler(sessionToken);
}
public ServiceResult<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptions(string sessionToken)
{
throw new NotSupportedException();
}
public ServiceResult<CampaignRoster> GetCampaign(string sessionToken, Guid campaignId)
{
throw new NotSupportedException();
}
public ServiceResult<bool> DeleteCampaign(string sessionToken, Guid campaignId)
{
throw new NotSupportedException();
}
public ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken)
{
throw new NotSupportedException();
}
public ServiceResult<IReadOnlyList<AdminUserSummary>> GetUsers(string sessionToken)
{
throw new NotSupportedException();
}
public ServiceResult<AdminUserSummary> UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList<string> roles)
{
throw new NotSupportedException();
}
public ServiceResult<bool> DeleteUser(string sessionToken, Guid userId)
{
throw new NotSupportedException();
}
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId)
{
throw new NotSupportedException();
}
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null)
{
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<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken)
{
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, 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();
}
public ServiceResult<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility)
{
throw new NotSupportedException();
}
public ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId)
{
throw new NotSupportedException();
}
public ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null)
{
throw new NotSupportedException();
}
public ServiceResult<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId)
{
throw new NotSupportedException();
}
public ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId)
{
throw new NotSupportedException();
}
public Func<string, ServiceResult<MeResponse>> GetMeHandler { get; init; } = _ => ServiceResult<MeResponse>.Failure("unexpected_call", "Unexpected GetMe call.");
public Func<string, ServiceResult<IReadOnlyList<CampaignSummary>>> GetCampaignsHandler { get; init; } = _ => ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unexpected_call", "Unexpected GetCampaigns call.");
}
[Fact] [Fact]
public void SessionTokenAccessor_ReadsSessionCookieFromHttpContext() public void SessionTokenAccessor_ReadsSessionCookieFromHttpContext()
{ {
@@ -27,7 +197,7 @@ public sealed class WorkspaceQueryServiceTests
GetCampaignsHandler = sessionToken => GetCampaignsHandler = sessionToken =>
{ {
Assert.Equal("server-session", sessionToken); Assert.Equal("server-session", sessionToken);
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success([new CampaignSummary(Guid.NewGuid(), "Alpha", "d6", new CampaignGmSummary(Guid.NewGuid(), "GM"), 1)]); return ServiceResult<IReadOnlyList<CampaignSummary>>.Success([new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), 1)]);
} }
}; };
@@ -40,10 +210,7 @@ public sealed class WorkspaceQueryServiceTests
[Fact] [Fact]
public async Task GetMeAsync_MapsUnauthorizedServiceResultToApiRequestException() public async Task GetMeAsync_MapsUnauthorizedServiceResultToApiRequestException()
{ {
var service = new StubGameService var service = new StubGameService { GetMeHandler = _ => ServiceResult<MeResponse>.Failure("unauthorized", "You must be logged in.") };
{
GetMeHandler = _ => ServiceResult<MeResponse>.Failure("unauthorized", "You must be logged in.")
};
var queryService = new WorkspaceQueryService(service, CreateSessionTokenAccessor("expired-session")); var queryService = new WorkspaceQueryService(service, CreateSessionTokenAccessor("expired-session"));
var exception = await Assert.ThrowsAsync<ApiRequestException>(queryService.GetMeAsync); var exception = await Assert.ThrowsAsync<ApiRequestException>(queryService.GetMeAsync);
@@ -57,49 +224,6 @@ public sealed class WorkspaceQueryServiceTests
{ {
var httpContext = new DefaultHttpContext(); var httpContext = new DefaultHttpContext();
httpContext.Request.Headers.Cookie = $"rpgroller_session={sessionToken}"; httpContext.Request.Headers.Cookie = $"rpgroller_session={sessionToken}";
return new WorkspaceSessionTokenAccessor(new HttpContextAccessor { HttpContext = httpContext }); return new(new HttpContextAccessor { HttpContext = httpContext });
} }
}
private sealed class StubGameService : IGameService
{
public Func<string, ServiceResult<MeResponse>> GetMeHandler { get; init; } =
_ => ServiceResult<MeResponse>.Failure("unexpected_call", "Unexpected GetMe call.");
public Func<string, ServiceResult<IReadOnlyList<CampaignSummary>>> GetCampaignsHandler { get; init; } =
_ => ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unexpected_call", "Unexpected GetCampaigns call.");
public IReadOnlyList<RulesetDefinition> GetRulesets() => throw new NotSupportedException();
public ServiceResult<UserSummary> Register(string username, string password, string displayName) => throw new NotSupportedException();
public ServiceResult<(UserSummary User, string SessionToken)> Login(string username, string password) => throw new NotSupportedException();
public void Logout(string sessionToken) => throw new NotSupportedException();
public UserSummary? GetUserBySession(string sessionToken) => throw new NotSupportedException();
public ServiceResult<MeResponse> GetMe(string sessionToken) => GetMeHandler(sessionToken);
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId) => throw new NotSupportedException();
public ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken) => GetCampaignsHandler(sessionToken);
public ServiceResult<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptions(string sessionToken) => throw new NotSupportedException();
public ServiceResult<CampaignRoster> GetCampaign(string sessionToken, Guid campaignId) => throw new NotSupportedException();
public ServiceResult<bool> DeleteCampaign(string sessionToken, Guid campaignId) => throw new NotSupportedException();
public ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken) => throw new NotSupportedException();
public ServiceResult<IReadOnlyList<AdminUserSummary>> GetUsers(string sessionToken) => throw new NotSupportedException();
public ServiceResult<AdminUserSummary> UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList<string> roles) => throw new NotSupportedException();
public ServiceResult<bool> DeleteUser(string sessionToken, Guid userId) => throw new NotSupportedException();
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId) => throw new NotSupportedException();
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null) => 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<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken) => 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, 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();
public ServiceResult<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility) => throw new NotSupportedException();
public ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId) => throw new NotSupportedException();
public ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null) => throw new NotSupportedException();
public ServiceResult<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId) => throw new NotSupportedException();
public ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId) => throw new NotSupportedException();
}
}

View File

@@ -1,6 +1,4 @@
using RpgRoller.Components.Pages; using RpgRoller.Components.Pages;
using RpgRoller.Contracts;
using RpgRoller.Domain;
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
@@ -14,15 +12,10 @@ public sealed class WorkspaceStateTests
var otherOwnerId = Guid.NewGuid(); var otherOwnerId = Guid.NewGuid();
var state = new WorkspaceState var state = new WorkspaceState
{ {
User = new UserSummary(userId, "user", "User", []), User = new(userId, "user", "User", []),
SelectedCampaign = new CampaignRoster( SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(gmId, "GM"), [
Guid.NewGuid(), new(Guid.NewGuid(), "Scout", otherOwnerId, Guid.NewGuid(), "Other Owner")
"Alpha", ])
"d6",
new CampaignGmSummary(gmId, "GM"),
[
new CharacterSummary(Guid.NewGuid(), "Scout", otherOwnerId, Guid.NewGuid(), "Other Owner")
])
}; };
Assert.Equal("You", state.OwnerLabel(userId)); Assert.Equal("You", state.OwnerLabel(userId));
@@ -35,17 +28,14 @@ public sealed class WorkspaceStateTests
public void SkillDefinitionLabel_FormatsD6RolemasterAndDefaultRulesets() public void SkillDefinitionLabel_FormatsD6RolemasterAndDefaultRulesets()
{ {
var skill = new CharacterSheetSkill(Guid.NewGuid(), null, "Awareness", "d100!+15", 1, true, 5); var skill = new CharacterSheetSkill(Guid.NewGuid(), null, "Awareness", "d100!+15", 1, true, 5);
var state = new WorkspaceState var state = new WorkspaceState { SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), []) };
{
SelectedCampaign = new CampaignRoster(Guid.NewGuid(), "Alpha", "d6", new CampaignGmSummary(Guid.NewGuid(), "GM"), [])
};
Assert.Equal("d100!+15, wild 1, fumble on", state.SkillDefinitionLabel(skill)); Assert.Equal("d100!+15, wild 1, fumble on", state.SkillDefinitionLabel(skill));
state.SelectedCampaign = new CampaignRoster(Guid.NewGuid(), "Alpha", "rolemaster", new CampaignGmSummary(Guid.NewGuid(), "GM"), []); state.SelectedCampaign = new(Guid.NewGuid(), "Alpha", "rolemaster", new(Guid.NewGuid(), "GM"), []);
Assert.Equal("Open-ended percentile: d100!+15, fumble <= 5", state.SkillDefinitionLabel(skill)); Assert.Equal("Open-ended percentile: d100!+15, fumble <= 5", state.SkillDefinitionLabel(skill));
state.SelectedCampaign = new CampaignRoster(Guid.NewGuid(), "Alpha", "dnd5e", new CampaignGmSummary(Guid.NewGuid(), "GM"), []); state.SelectedCampaign = new(Guid.NewGuid(), "Alpha", "dnd5e", new(Guid.NewGuid(), "GM"), []);
Assert.Equal("d100!+15", state.SkillDefinitionLabel(skill)); Assert.Equal("d100!+15", state.SkillDefinitionLabel(skill));
} }
@@ -58,17 +48,12 @@ public sealed class WorkspaceStateTests
var otherCharacter = new CharacterSummary(Guid.NewGuid(), "Other", Guid.NewGuid(), Guid.NewGuid(), "Other"); var otherCharacter = new CharacterSummary(Guid.NewGuid(), "Other", Guid.NewGuid(), Guid.NewGuid(), "Other");
var state = new WorkspaceState var state = new WorkspaceState
{ {
User = new UserSummary(userId, "user", "User", []), User = new(userId, "user", "User", []),
SelectedCampaign = new CampaignRoster( SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), [ownedCharacter, secondOwnedCharacter, otherCharacter]),
Guid.NewGuid(),
"Alpha",
"d6",
new CampaignGmSummary(Guid.NewGuid(), "GM"),
[ownedCharacter, secondOwnedCharacter, otherCharacter]),
SelectedCharacterId = secondOwnedCharacter.Id, SelectedCharacterId = secondOwnedCharacter.Id,
ActiveCharacterId = ownedCharacter.Id, ActiveCharacterId = ownedCharacter.Id,
SelectedCharacterSkills = [new CharacterSheetSkill(Guid.NewGuid(), null, "Stealth", "2D+1", 1, true, null)], SelectedCharacterSkills = [new(Guid.NewGuid(), null, "Stealth", "2D+1", 1, true, null)],
SelectedCharacterSkillGroups = [new CharacterSheetSkillGroup(Guid.NewGuid(), "Combat", "2D+1", 1, true, null)] SelectedCharacterSkillGroups = [new(Guid.NewGuid(), "Combat", "2D+1", 1, true, null)]
}; };
Assert.Equal(2, state.PlaySelectedCampaign!.Characters.Length); Assert.Equal(2, state.PlaySelectedCampaign!.Characters.Length);
@@ -89,8 +74,8 @@ public sealed class WorkspaceStateTests
var adminId = Guid.NewGuid(); var adminId = Guid.NewGuid();
var state = new WorkspaceState var state = new WorkspaceState
{ {
User = new UserSummary(adminId, "admin", "Admin", [UserRoles.Admin]), User = new(adminId, "admin", "Admin", [UserRoles.Admin]),
SelectedCampaign = new CampaignRoster(Guid.NewGuid(), "Alpha", "d6", new CampaignGmSummary(adminId, "Admin"), []), SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(adminId, "Admin"), []),
CurrentScreen = "admin", CurrentScreen = "admin",
ConnectionState = "reconnecting" ConnectionState = "reconnecting"
}; };
@@ -113,4 +98,4 @@ public sealed class WorkspaceStateTests
Assert.Equal("ok", state.ConnectionStateCssClass); Assert.Equal("ok", state.ConnectionStateCssClass);
Assert.Equal("rr-app app-play", state.AppCssClass); Assert.Equal("rr-app app-play", state.AppCssClass);
} }
} }

View File

@@ -97,4 +97,4 @@ public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>
} }
private readonly WebApplicationFactory<Program> m_BaseFactory; private readonly WebApplicationFactory<Program> m_BaseFactory;
} }

View File

@@ -36,10 +36,10 @@ internal static class AdminEndpoints
return TypedResults.Unauthorized(); return TypedResults.Unauthorized();
if (!user.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase)) if (!user.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase))
return ApiResultMapper.ToBadRequest(new ServiceError("forbidden", "Admin role is required.")); return ApiResultMapper.ToBadRequest(new("forbidden", "Admin role is required."));
if (string.IsNullOrWhiteSpace(databaseFile.Path) || !File.Exists(databaseFile.Path)) if (string.IsNullOrWhiteSpace(databaseFile.Path) || !File.Exists(databaseFile.Path))
return ApiResultMapper.ToBadRequest(new ServiceError("database_unavailable", "SQLite database file is not available.")); return ApiResultMapper.ToBadRequest(new("database_unavailable", "SQLite database file is not available."));
var stream = new FileStream(databaseFile.Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); var stream = new FileStream(databaseFile.Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
return TypedResults.File(stream, "application/octet-stream", Path.GetFileName(databaseFile.Path)); return TypedResults.File(stream, "application/octet-stream", Path.GetFileName(databaseFile.Path));
@@ -47,4 +47,4 @@ internal static class AdminEndpoints
return group; return group;
} }
} }

View File

@@ -18,4 +18,4 @@ public static class ApiEndpointRegistration
authenticatedApi.MapRollEndpoints(); authenticatedApi.MapRollEndpoints();
authenticatedApi.MapStateEventEndpoints(); authenticatedApi.MapStateEventEndpoints();
} }
} }

View File

@@ -21,4 +21,4 @@ internal static class ApiResultMapper
{ {
return TypedResults.BadRequest(new ApiError(error.Message, error.Code)); return TypedResults.BadRequest(new ApiError(error.Message, error.Code));
} }
} }

View File

@@ -51,4 +51,4 @@ internal static class CampaignEndpoints
return group; return group;
} }
} }

View File

@@ -51,4 +51,4 @@ internal static class CharacterEndpoints
return group; return group;
} }
} }

View File

@@ -1,4 +1,3 @@
using RpgRoller.Contracts;
using RpgRoller.Services; using RpgRoller.Services;
namespace RpgRoller.Api; namespace RpgRoller.Api;
@@ -15,4 +14,4 @@ internal static class RollEndpoints
return group; return group;
} }
} }

View File

@@ -57,4 +57,4 @@ internal static class SkillEndpoints
return group; return group;
} }
} }

View File

@@ -13,9 +13,7 @@ internal static class StateEventEndpoints
var stateResult = game.GetCampaignStateSnapshot(sessionToken, campaignId); var stateResult = game.GetCampaignStateSnapshot(sessionToken, campaignId);
if (!stateResult.Succeeded) if (!stateResult.Succeeded)
{ {
return stateResult.Error!.Code == "unauthorized" return stateResult.Error!.Code == "unauthorized" ? TypedResults.Unauthorized() : TypedResults.BadRequest(new ApiError(stateResult.Error.Message, stateResult.Error.Code));
? TypedResults.Unauthorized()
: TypedResults.BadRequest(new ApiError(stateResult.Error.Message, stateResult.Error.Code));
} }
context.Response.Headers.CacheControl = "no-cache"; context.Response.Headers.CacheControl = "no-cache";
@@ -60,11 +58,8 @@ internal static class StateEventEndpoints
private static Task WriteStateEventAsync(HttpResponse response, CampaignStateSnapshot snapshot) private static Task WriteStateEventAsync(HttpResponse response, CampaignStateSnapshot snapshot)
{ {
var characterVersions = string.Join( var characterVersions = string.Join(",", snapshot.CharacterVersions.Select(version => $"{{\"characterId\":\"{version.CharacterId}\",\"version\":{version.Version}}}"));
",",
snapshot.CharacterVersions.Select(version => $"{{\"characterId\":\"{version.CharacterId}\",\"version\":{version.Version}}}"));
return response.WriteAsync( return response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{snapshot.CampaignId}\",\"totalVersion\":{snapshot.TotalVersion},\"rosterVersion\":{snapshot.RosterVersion},\"logVersion\":{snapshot.LogVersion},\"characterVersions\":[{characterVersions}]}}\n\n");
$"event: state\ndata: {{\"campaignId\":\"{snapshot.CampaignId}\",\"totalVersion\":{snapshot.TotalVersion},\"rosterVersion\":{snapshot.RosterVersion},\"logVersion\":{snapshot.LogVersion},\"characterVersions\":[{characterVersions}]}}\n\n");
} }
} }

View File

@@ -22,8 +22,9 @@
</html> </html>
@code { @code {
[CascadingParameter] [CascadingParameter]
private Microsoft.AspNetCore.Http.HttpContext? HttpContext { get; set; } private HttpContext? HttpContext { get; set; }
private string BaseHref private string BaseHref
{ {
@@ -36,4 +37,5 @@
return pathBase.EndsWith('/') ? pathBase : $"{pathBase}/"; return pathBase.EndsWith('/') ? pathBase : $"{pathBase}/";
} }
} }
}
}

View File

@@ -1,4 +1,4 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@attribute [ExcludeFromCodeCoverage] @attribute [ExcludeFromCodeCoverage]
@Body @Body

View File

@@ -70,4 +70,4 @@ public enum HomeViewMode
Loading, Loading,
Anonymous, Anonymous,
Workspace Workspace
} }

View File

@@ -24,4 +24,4 @@
case HomeViewMode.Workspace: case HomeViewMode.Workspace:
<Workspace LoggedOut="OnLoggedOutAsync"/> <Workspace LoggedOut="OnLoggedOutAsync"/>
break; break;
} }

View File

@@ -77,4 +77,4 @@ public partial class Home
[Inject] [Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!; private RpgRollerApiClient ApiClient { get; set; } = null!;
} }

View File

@@ -68,4 +68,4 @@
</section> </section>
</main> </main>
</div> </div>
</div> </div>

View File

@@ -28,9 +28,7 @@ public partial class AdminHome
if (!IsCurrentUserAdmin) if (!IsCurrentUserAdmin)
return; return;
Users = (await ApiClient.RequestAsync<IReadOnlyList<AdminUserSummary>>("GET", "/api/admin/users")) Users = (await ApiClient.RequestAsync<IReadOnlyList<AdminUserSummary>>("GET", "/api/admin/users")).OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase).ToList();
.OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase)
.ToList();
} }
catch (ApiRequestException ex) when (ex.StatusCode == 401) catch (ApiRequestException ex) when (ex.StatusCode == 401)
{ {
@@ -92,10 +90,7 @@ public partial class AdminHome
try try
{ {
IReadOnlyList<string> roles = HasAdminRole(user) ? Array.Empty<string>() : [UserRoles.Admin]; IReadOnlyList<string> roles = HasAdminRole(user) ? Array.Empty<string>() : [UserRoles.Admin];
_ = await ApiClient.RequestAsync<AdminUserSummary>( _ = await ApiClient.RequestAsync<AdminUserSummary>("PUT", $"/api/admin/users/{user.Id}/roles", new UpdateUserRolesRequest(roles));
"PUT",
$"/api/admin/users/{user.Id}/roles",
new UpdateUserRolesRequest(roles));
await ReloadUsersAsync(); await ReloadUsersAsync();
SetStatus("User roles updated.", false); SetStatus("User roles updated.", false);
@@ -138,9 +133,7 @@ public partial class AdminHome
private async Task ReloadUsersAsync() private async Task ReloadUsersAsync()
{ {
Users = (await ApiClient.RequestAsync<IReadOnlyList<AdminUserSummary>>("GET", "/api/admin/users")) Users = (await ApiClient.RequestAsync<IReadOnlyList<AdminUserSummary>>("GET", "/api/admin/users")).OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase).ToList();
.OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase)
.ToList();
} }
private static bool HasAdminRole(UserSummary user) private static bool HasAdminRole(UserSummary user)
@@ -184,22 +177,32 @@ public partial class AdminHome
private List<AdminUserSummary> Users { get; set; } = []; private List<AdminUserSummary> Users { get; set; } = [];
private string? StatusMessage { get; set; } private string? StatusMessage { get; set; }
private bool StatusIsError { get; set; } private bool StatusIsError { get; set; }
private IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems
{ private IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems =>
get [
new AppHeaderMenuItem
{ {
return Label = "Play",
[ IsActive = false,
new AppHeaderMenuItem { Label = "Play", IsActive = false, OnSelected = OpenPlayAsync }, OnSelected = OpenPlayAsync
new AppHeaderMenuItem { Label = "Campaign Management", IsActive = false, OnSelected = OpenCampaignManagementAsync }, },
new AppHeaderMenuItem { Label = "Admin", IsActive = true, OnSelected = OpenAdminAsync } new AppHeaderMenuItem
]; {
Label = "Campaign Management",
IsActive = false,
OnSelected = OpenCampaignManagementAsync
},
new AppHeaderMenuItem
{
Label = "Admin",
IsActive = true,
OnSelected = OpenAdminAsync
} }
} ];
[Parameter] [Parameter]
public EventCallback<string?> LoggedOut { get; set; } public EventCallback<string?> LoggedOut { get; set; }
[Parameter] [Parameter]
public EventCallback<string> WorkspaceRequested { get; set; } public EventCallback<string> WorkspaceRequested { get; set; }
} }

View File

@@ -3,11 +3,15 @@
<h1>@Title</h1> <h1>@Title</h1>
@if (User is null) @if (User is null)
{ {
<p class="header-identity"><strong>Loading user...</strong></p> <p class="header-identity">
<strong>Loading user...</strong>
</p>
} }
else else
{ {
<p class="header-identity"><strong>@User.DisplayName</strong> <span class="muted">(@User.Username)</span></p> <p class="header-identity">
<strong>@User.DisplayName</strong> <span class="muted">(@User.Username)</span>
</p>
} }
@if (ShowCampaign) @if (ShowCampaign)
{ {
@@ -50,4 +54,4 @@
</div> </div>
} }
</div> </div>
</header> </header>

View File

@@ -57,4 +57,4 @@ public sealed class AppHeaderMenuItem
public string Label { get; init; } = string.Empty; public string Label { get; init; } = string.Empty;
public bool IsActive { get; init; } public bool IsActive { get; init; }
public Func<Task>? OnSelected { get; init; } public Func<Task>? OnSelected { get; init; }
} }

View File

@@ -63,4 +63,4 @@
</form> </form>
</section> </section>
</div> </div>
</main> </main>

View File

@@ -1,5 +1,7 @@
<aside @ref="LogPanelRef" class="card log-panel"> <aside @ref="LogPanelRef" class="card log-panel">
<div class="section-head"><h2>Campaign Log</h2></div> <div class="section-head">
<h2>Campaign Log</h2>
</div>
<div @ref="LogFeedRef" class="log-panel-feed"> <div @ref="LogFeedRef" class="log-panel-feed">
@if (IsCampaignDataLoading) @if (IsCampaignDataLoading)
{ {
@@ -47,9 +49,12 @@
} }
</span> </span>
} }
<span class="log-meta"><span class="badge @entry.VisibilityStyle">@entry.VisibilityLabel</span> <span class="log-meta">
<span class="badge @entry.VisibilityStyle">@entry.VisibilityLabel</span>
<time <time
title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time> title="@entry.TimestampUtc.ToString("O")">
@entry.TimestampUtc.ToLocalTime().ToString("g")
</time>
</span> </span>
</button> </button>
@if (isExpanded) @if (isExpanded)
@@ -78,29 +83,29 @@
<section class="custom-roll-panel" aria-label="Custom roll panel"> <section class="custom-roll-panel" aria-label="Custom roll panel">
<form class="custom-roll-composer" @onsubmit="SubmitCustomRollAsync" @onsubmit:preventDefault> <form class="custom-roll-composer" @onsubmit="SubmitCustomRollAsync" @onsubmit:preventDefault>
<div class="custom-roll-composer-head"> <div class="custom-roll-composer-head">
<label for="custom-roll-expression" class="custom-roll-label">Custom roll</label> <label for="custom-roll-expression" class="custom-roll-label">Custom roll</label>
<span class="muted">@CustomRollStatusText</span> <span class="muted">@CustomRollStatusText</span>
</div> </div>
<div class="custom-roll-composer-row"> <div class="custom-roll-composer-row">
<input id="custom-roll-expression" <input id="custom-roll-expression"
@key="CustomRollInputVersion" @key="CustomRollInputVersion"
@ref="CustomRollInputRef" @ref="CustomRollInputRef"
class="@CustomRollInputCssClass" class="@CustomRollInputCssClass"
@bind="CustomRollExpression" @bind="CustomRollExpression"
@bind:event="oninput" @bind:event="oninput"
placeholder="@CustomRollPlaceholder" placeholder="@CustomRollPlaceholder"
title="@CustomRollInputTitle" title="@CustomRollInputTitle"
aria-invalid="@HasCustomRollError" aria-invalid="@HasCustomRollError"
aria-describedby="@CustomRollInputDescribedBy" aria-describedby="@CustomRollInputDescribedBy"
disabled="@IsCustomRollDisabled"/> disabled="@IsCustomRollDisabled"/>
<button type="submit" disabled="@IsCustomRollDisabled">Roll</button> <button type="submit" disabled="@IsCustomRollDisabled">Roll</button>
</div> </div>
<p class="field-help">@CustomRollHelpText</p> <p class="field-help">@CustomRollHelpText</p>
@if (HasCustomRollError) @if (HasCustomRollError)
{ {
<p id="@CustomRollErrorElementId" class="field-error" role="alert">@CustomRollErrorMessage</p> <p id="@CustomRollErrorElementId" class="field-error" role="alert">@CustomRollErrorMessage</p>
} }
</form> </form>
</section> </section>
</aside> </aside>

View File

@@ -1,7 +1,6 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop; using Microsoft.JSInterop;
using RpgRoller.Components;
using RpgRoller.Contracts; using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls; namespace RpgRoller.Components.Pages.HomeControls;
@@ -9,6 +8,8 @@ namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public partial class CampaignLogPanel public partial class CampaignLogPanel
{ {
private sealed record EventBadgeView(string Label, string Tone);
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
var currentLastRollId = CampaignLog.LastOrDefault()?.RollId; var currentLastRollId = CampaignLog.LastOrDefault()?.RollId;
@@ -37,6 +38,96 @@ public partial class CampaignLogPanel
LastRenderedLogRollId = currentLastRollId; LastRenderedLogRollId = currentLastRollId;
} }
private async Task SubmitCustomRollAsync()
{
CustomRollState.ResetValidation();
var expression = CustomRollState.Model.Expression.Trim();
if (string.IsNullOrWhiteSpace(expression))
{
SetCustomRollError("Enter a roll expression first.");
return;
}
if (!SelectedCharacterId.HasValue)
{
SetCustomRollError("Select a character to make a custom roll.");
return;
}
IsSubmittingCustomRoll = true;
try
{
var roll = await ApiClient.RequestAsync<RollResult>("POST", $"/api/characters/{SelectedCharacterId.Value}/custom-rolls", new
{
expression,
visibility = NormalizedRollVisibility
});
CustomRollState.Model.Expression = string.Empty;
CustomRollState.ResetValidation();
CustomRollInputVersion += 1;
await CustomRollCreated.InvokeAsync(roll);
await JS.InvokeVoidAsync("rpgRollerApi.clearInputValue", CustomRollInputRef);
await InvokeAsync(StateHasChanged);
}
catch (ApiRequestException ex) when (string.Equals(ex.ErrorCode, "invalid_expression", StringComparison.Ordinal))
{
SetCustomRollError(ex.Message);
await InvokeAsync(StateHasChanged);
}
catch (ApiRequestException ex)
{
await ErrorOccurred.InvokeAsync(ex.Message);
}
finally
{
IsSubmittingCustomRoll = false;
}
}
private void SetCustomRollError(string message)
{
CustomRollState.Errors["expression"] = message;
}
private static IReadOnlyList<EventBadgeView> GetEventBadges(CampaignLogListEntry entry)
{
return (entry.EventBadges ?? []).Select(ToEventBadgeView).Where(badge => badge is not null).Cast<EventBadgeView>().ToArray();
}
private static bool HasSummary(CampaignLogListEntry entry)
{
return (entry.EventBadges?.Length ?? 0) > 0 || !string.IsNullOrWhiteSpace(entry.SummaryText);
}
private static EventBadgeView? ToEventBadgeView(string code)
{
return code switch
{
"w6" => new("Wild 6", "positive"),
"w1" => new("Wild 1", "danger"),
"n20" => new("Nat 20", "positive"),
"n1" => new("Nat 1", "danger"),
"rf" => new("Fumble", "danger"),
"r100" => new("100", "rare"),
"r66" => new("66", "rare"),
_ => null
};
}
private static string LogEntryCssClass(CampaignLogListEntry entry, bool isExpanded, bool isFresh)
{
var classes = new List<string> { entry.VisibilityStyle };
if (isExpanded)
classes.Add("expanded");
if (isFresh)
classes.Add("fresh");
return string.Join(" ", classes);
}
[Inject] [Inject]
private IJSRuntime JS { get; set; } = null!; private IJSRuntime JS { get; set; } = null!;
@@ -97,105 +188,6 @@ public partial class CampaignLogPanel
[Parameter] [Parameter]
public EventCallback<string> ErrorOccurred { get; set; } public EventCallback<string> ErrorOccurred { get; set; }
private async Task SubmitCustomRollAsync()
{
CustomRollState.ResetValidation();
var expression = CustomRollState.Model.Expression.Trim();
if (string.IsNullOrWhiteSpace(expression))
{
SetCustomRollError("Enter a roll expression first.");
return;
}
if (!SelectedCharacterId.HasValue)
{
SetCustomRollError("Select a character to make a custom roll.");
return;
}
IsSubmittingCustomRoll = true;
try
{
var roll = await ApiClient.RequestAsync<RollResult>(
"POST",
$"/api/characters/{SelectedCharacterId.Value}/custom-rolls",
new
{
expression,
visibility = NormalizedRollVisibility
});
CustomRollState.Model.Expression = string.Empty;
CustomRollState.ResetValidation();
CustomRollInputVersion += 1;
await CustomRollCreated.InvokeAsync(roll);
await JS.InvokeVoidAsync("rpgRollerApi.clearInputValue", CustomRollInputRef);
await InvokeAsync(StateHasChanged);
}
catch (ApiRequestException ex) when (string.Equals(ex.ErrorCode, "invalid_expression", StringComparison.Ordinal))
{
SetCustomRollError(ex.Message);
await InvokeAsync(StateHasChanged);
}
catch (ApiRequestException ex)
{
await ErrorOccurred.InvokeAsync(ex.Message);
}
finally
{
IsSubmittingCustomRoll = false;
}
}
private void SetCustomRollError(string message)
{
CustomRollState.Errors["expression"] = message;
}
private static IReadOnlyList<EventBadgeView> GetEventBadges(CampaignLogListEntry entry)
{
return (entry.EventBadges ?? [])
.Select(ToEventBadgeView)
.Where(badge => badge is not null)
.Cast<EventBadgeView>()
.ToArray();
}
private static bool HasSummary(CampaignLogListEntry entry)
{
return (entry.EventBadges?.Length ?? 0) > 0 || !string.IsNullOrWhiteSpace(entry.SummaryText);
}
private static EventBadgeView? ToEventBadgeView(string code)
{
return code switch
{
"w6" => new EventBadgeView("Wild 6", "positive"),
"w1" => new EventBadgeView("Wild 1", "danger"),
"n20" => new EventBadgeView("Nat 20", "positive"),
"n1" => new EventBadgeView("Nat 1", "danger"),
"rf" => new EventBadgeView("Fumble", "danger"),
"r100" => new EventBadgeView("100", "rare"),
"r66" => new EventBadgeView("66", "rare"),
_ => null
};
}
private static string LogEntryCssClass(CampaignLogListEntry entry, bool isExpanded, bool isFresh)
{
var classes = new List<string> { entry.VisibilityStyle };
if (isExpanded)
classes.Add("expanded");
if (isFresh)
classes.Add("fresh");
return string.Join(" ", classes);
}
private sealed record EventBadgeView(string Label, string Tone);
private bool HasCustomRollError => CustomRollState.Errors.ContainsKey("expression"); private bool HasCustomRollError => CustomRollState.Errors.ContainsKey("expression");
private string? CustomRollErrorMessage => CustomRollState.Errors.GetValueOrDefault("expression"); private string? CustomRollErrorMessage => CustomRollState.Errors.GetValueOrDefault("expression");
private bool IsCustomRollDisabled => IsCampaignDataLoading || IsMutating || IsSubmittingCustomRoll || !SelectedCharacterId.HasValue; private bool IsCustomRollDisabled => IsCampaignDataLoading || IsMutating || IsSubmittingCustomRoll || !SelectedCharacterId.HasValue;
@@ -203,24 +195,27 @@ public partial class CampaignLogPanel
private string? CustomRollInputTitle => HasCustomRollError ? CustomRollErrorMessage : null; private string? CustomRollInputTitle => HasCustomRollError ? CustomRollErrorMessage : null;
private string CustomRollErrorElementId => "custom-roll-expression-error"; private string CustomRollErrorElementId => "custom-roll-expression-error";
private string? CustomRollInputDescribedBy => HasCustomRollError ? CustomRollErrorElementId : null; private string? CustomRollInputDescribedBy => HasCustomRollError ? CustomRollErrorElementId : null;
private string CustomRollPlaceholder => SelectedCampaignRulesetId.ToLowerInvariant() switch private string CustomRollPlaceholder => SelectedCampaignRulesetId.ToLowerInvariant() switch
{ {
RulesetFormHelpers.RulesetIds.D6 => "e.g. 5D+4", RulesetFormHelpers.RulesetIds.D6 => "e.g. 5D+4",
RulesetFormHelpers.RulesetIds.Dnd5e => "e.g. 2d12+2", RulesetFormHelpers.RulesetIds.Dnd5e => "e.g. 2d12+2",
RulesetFormHelpers.RulesetIds.Rolemaster => "e.g. d10, 15d10, d100!+85", RulesetFormHelpers.RulesetIds.Rolemaster => "e.g. d10, 15d10, d100!+85",
_ => "Enter a roll expression" _ => "Enter a roll expression"
}; };
private string CustomRollStatusText => SelectedCharacterId.HasValue && !string.IsNullOrWhiteSpace(SelectedCharacterName)
? $"For {SelectedCharacterName} • {RollVisibilityLabel}" private string CustomRollStatusText => SelectedCharacterId.HasValue && !string.IsNullOrWhiteSpace(SelectedCharacterName) ? $"For {SelectedCharacterName} • {RollVisibilityLabel}" : "Select a character to enable";
: "Select a character to enable";
private string CustomRollHelpText => SelectedCampaignRulesetId.ToLowerInvariant() switch private string CustomRollHelpText => SelectedCampaignRulesetId.ToLowerInvariant() switch
{ {
RulesetFormHelpers.RulesetIds.D6 => "Uses the campaign ruleset. D6 custom rolls use one wild die and allow fumbles.", RulesetFormHelpers.RulesetIds.D6 => "Uses the campaign ruleset. D6 custom rolls use one wild die and allow fumbles.",
RulesetFormHelpers.RulesetIds.Rolemaster => $"{RulesetFormHelpers.RolemasterExampleText()}. Logged for the selected character.", RulesetFormHelpers.RulesetIds.Rolemaster => $"{RulesetFormHelpers.RolemasterExampleText()}. Logged for the selected character.",
_ => "Uses the selected campaign ruleset and current visibility." _ => "Uses the selected campaign ruleset and current visibility."
}; };
private string RollVisibilityLabel => string.Equals(NormalizedRollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "Private" : "Public"; private string RollVisibilityLabel => string.Equals(NormalizedRollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "Private" : "Public";
private string NormalizedRollVisibility => string.Equals(RollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public"; private string NormalizedRollVisibility => string.Equals(RollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public";
private string CustomRollExpression private string CustomRollExpression
{ {
get => CustomRollState.Model.Expression; get => CustomRollState.Model.Expression;
@@ -231,4 +226,4 @@ public partial class CampaignLogPanel
CustomRollState.ResetValidation(); CustomRollState.ResetValidation();
} }
} }
} }

View File

@@ -129,4 +129,4 @@
</form> </form>
</section> </section>
</div> </div>
} }

View File

@@ -115,4 +115,4 @@ public partial class CampaignManagementPanel
[Parameter] [Parameter]
public EventCallback<CharacterSummary> DeleteCharacterRequested { get; set; } public EventCallback<CharacterSummary> DeleteCharacterRequested { get; set; }
} }

View File

@@ -48,4 +48,4 @@
</form> </form>
</section> </section>
</div> </div>
} }

View File

@@ -54,9 +54,7 @@ public partial class CharacterFormModal
character = await ApiClient.RequestAsync<CharacterSummary>("PUT", $"/api/characters/{EditingCharacterId.Value}", new UpdateCharacterRequest(FormState.Model.Name.Trim(), campaignId, ownerUsername)); character = await ApiClient.RequestAsync<CharacterSummary>("PUT", $"/api/characters/{EditingCharacterId.Value}", new UpdateCharacterRequest(FormState.Model.Name.Trim(), campaignId, ownerUsername));
} }
else else
{
character = await ApiClient.RequestAsync<CharacterSummary>("POST", "/api/characters", new CreateCharacterRequest(FormState.Model.Name.Trim(), campaignId!.Value)); character = await ApiClient.RequestAsync<CharacterSummary>("POST", "/api/characters", new CreateCharacterRequest(FormState.Model.Name.Trim(), campaignId!.Value));
}
await CharacterSaved.InvokeAsync(character.CampaignId); await CharacterSaved.InvokeAsync(character.CampaignId);
} }
@@ -121,4 +119,4 @@ public partial class CharacterFormModal
[Parameter] [Parameter]
public EventCallback CancelRequested { get; set; } public EventCallback CancelRequested { get; set; }
} }

View File

@@ -41,8 +41,12 @@
<span aria-hidden="true" class="emoji">✏️</span> <span aria-hidden="true" class="emoji">✏️</span>
<span class="sr-only">Edit character</span> <span class="sr-only">Edit character</span>
</button> </button>
<h3 class="skills-heading">@SelectedCharacter.Name <span <h3 class="skills-heading">
class="muted">| Owner: @OwnerLabel(SelectedCharacter.OwnerUserId) | Campaign: @SelectedCampaign.Name</span> @SelectedCharacter.Name
<span
class="muted">
| Owner: @OwnerLabel(SelectedCharacter.OwnerUserId) | Campaign: @SelectedCampaign.Name
</span>
</h3> </h3>
<div class="skill-filter-wrap"> <div class="skill-filter-wrap">
<label class="sr-only" for="skill-filter-input">Filter skills</label> <label class="sr-only" for="skill-filter-input">Filter skills</label>
@@ -130,6 +134,7 @@
</button> </button>
</article> </article>
} }
<div class="character-panel-fill" aria-hidden="true"></div> <div class="character-panel-fill" aria-hidden="true"></div>
} }
</section> </section>
@@ -242,4 +247,4 @@
AvailableSkillGroups="SelectedCharacterSkillGroups" AvailableSkillGroups="SelectedCharacterSkillGroups"
IsMutating="IsMutating" IsMutating="IsMutating"
SkillSaved="OnSkillUpdatedAsync" SkillSaved="OnSkillUpdatedAsync"
CancelRequested="CloseSkillModals"/> CancelRequested="CloseSkillModals"/>

View File

@@ -1,5 +1,4 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts; using RpgRoller.Contracts;
@@ -10,9 +9,7 @@ public partial class CharacterPanel
{ {
private void OpenCreateSkillModal(Guid? skillGroupId = null) private void OpenCreateSkillModal(Guid? skillGroupId = null)
{ {
var selectedGroup = skillGroupId.HasValue var selectedGroup = skillGroupId.HasValue ? SelectedCharacterSkillGroups.FirstOrDefault(group => group.Id == skillGroupId.Value) : null;
? SelectedCharacterSkillGroups.FirstOrDefault(group => group.Id == skillGroupId.Value)
: null;
CreateSkillInitialModel = new() CreateSkillInitialModel = new()
{ {
@@ -156,9 +153,7 @@ public partial class CharacterPanel
SkillGroupState.Errors["fumbleRange"] = "Open-ended Rolemaster groups require a fumble range."; SkillGroupState.Errors["fumbleRange"] = "Open-ended Rolemaster groups require a fumble range.";
} }
else else
{
SkillGroupState.Model.FumbleRange = null; SkillGroupState.Model.FumbleRange = null;
}
if (!IsD6Ruleset) if (!IsD6Ruleset)
{ {
@@ -179,15 +174,7 @@ public partial class CharacterPanel
try try
{ {
var selectedCharacterId = SelectedCharacterId!.Value; var selectedCharacterId = SelectedCharacterId!.Value;
var createdGroup = await ApiClient.RequestAsync<SkillGroupSummary>( var createdGroup = await ApiClient.RequestAsync<SkillGroupSummary>("POST", $"/api/characters/{selectedCharacterId}/skill-groups", new CreateSkillGroupRequest(SkillGroupState.Model.Name.Trim(), SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice, SkillGroupState.Model.AllowFumble, SkillGroupState.Model.FumbleRange));
"POST",
$"/api/characters/{selectedCharacterId}/skill-groups",
new CreateSkillGroupRequest(
SkillGroupState.Model.Name.Trim(),
SkillGroupState.Model.DiceRollDefinition.Trim(),
SkillGroupState.Model.WildDice,
SkillGroupState.Model.AllowFumble,
SkillGroupState.Model.FumbleRange));
CloseSkillGroupModals(); CloseSkillGroupModals();
await SkillGroupCreated.InvokeAsync(createdGroup.Id); await SkillGroupCreated.InvokeAsync(createdGroup.Id);
} }
@@ -220,9 +207,7 @@ public partial class CharacterPanel
SkillGroupState.Errors["fumbleRange"] = "Open-ended Rolemaster groups require a fumble range."; SkillGroupState.Errors["fumbleRange"] = "Open-ended Rolemaster groups require a fumble range.";
} }
else else
{
SkillGroupState.Model.FumbleRange = null; SkillGroupState.Model.FumbleRange = null;
}
if (!IsD6Ruleset) if (!IsD6Ruleset)
{ {
@@ -243,15 +228,7 @@ public partial class CharacterPanel
try try
{ {
var editingSkillGroupId = EditingSkillGroupId!.Value; var editingSkillGroupId = EditingSkillGroupId!.Value;
var updatedGroup = await ApiClient.RequestAsync<SkillGroupSummary>( var updatedGroup = await ApiClient.RequestAsync<SkillGroupSummary>("PUT", $"/api/skill-groups/{editingSkillGroupId}", new UpdateSkillGroupRequest(SkillGroupState.Model.Name.Trim(), SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice, SkillGroupState.Model.AllowFumble, SkillGroupState.Model.FumbleRange));
"PUT",
$"/api/skill-groups/{editingSkillGroupId}",
new UpdateSkillGroupRequest(
SkillGroupState.Model.Name.Trim(),
SkillGroupState.Model.DiceRollDefinition.Trim(),
SkillGroupState.Model.WildDice,
SkillGroupState.Model.AllowFumble,
SkillGroupState.Model.FumbleRange));
CloseSkillGroupModals(); CloseSkillGroupModals();
await SkillGroupUpdated.InvokeAsync(updatedGroup.Id); await SkillGroupUpdated.InvokeAsync(updatedGroup.Id);
} }
@@ -297,8 +274,7 @@ public partial class CharacterPanel
return true; return true;
var filter = SkillFilterText.Trim(); var filter = SkillFilterText.Trim();
return skill.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) || return skill.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) || skill.DiceRollDefinition.Contains(filter, StringComparison.OrdinalIgnoreCase);
skill.DiceRollDefinition.Contains(filter, StringComparison.OrdinalIgnoreCase);
} }
private static string InitialsFor(string value) private static string InitialsFor(string value)
@@ -340,9 +316,8 @@ public partial class CharacterPanel
private bool IsD6Ruleset => RulesetFormHelpers.IsD6(SelectedCampaignRulesetId); private bool IsD6Ruleset => RulesetFormHelpers.IsD6(SelectedCampaignRulesetId);
private bool IsRolemasterRuleset => RulesetFormHelpers.IsRolemaster(SelectedCampaignRulesetId); private bool IsRolemasterRuleset => RulesetFormHelpers.IsRolemaster(SelectedCampaignRulesetId);
private bool IsSkillGroupRolemasterOpenEnded => RulesetFormHelpers.IsRolemasterOpenEndedExpression(SkillGroupState.Model.DiceRollDefinition); private bool IsSkillGroupRolemasterOpenEnded => RulesetFormHelpers.IsRolemasterOpenEndedExpression(SkillGroupState.Model.DiceRollDefinition);
private string SkillGroupExpressionHelpText => IsRolemasterRuleset
? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed." private string SkillGroupExpressionHelpText => IsRolemasterRuleset ? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed." : "Enter the default expression for skills created in this group.";
: "Enter the default expression for skills created in this group.";
private bool ShowCreateSkillModal { get; set; } private bool ShowCreateSkillModal { get; set; }
private bool ShowEditSkillModal { get; set; } private bool ShowEditSkillModal { get; set; }
@@ -432,4 +407,4 @@ public partial class CharacterPanel
[Parameter] [Parameter]
public EventCallback<Guid> RollRequested { get; set; } public EventCallback<Guid> RollRequested { get; set; }
} }

View File

@@ -6,4 +6,4 @@
<span class="@RollDieCssClass(die)" title="@RollDieTitle(die)">@RollDieDisplay(die)</span> <span class="@RollDieCssClass(die)" title="@RollDieTitle(die)">@RollDieDisplay(die)</span>
} }
</div> </div>
} }

View File

@@ -38,7 +38,7 @@ public partial class RollDiceStrip
return die.Kind switch return die.Kind switch
{ {
_ => RollDieGlyph(die.Roll) _ => RollDieGlyph(die.Roll)
}; };
} }
@@ -81,10 +81,7 @@ public partial class RollDiceStrip
private static bool IsRolemasterDie(RollDieResult die) private static bool IsRolemasterDie(RollDieResult die)
{ {
return die.Kind is RollDieKinds.RolemasterStandard or return die.Kind is RollDieKinds.RolemasterStandard or RollDieKinds.RolemasterOpenEndedInitial or RollDieKinds.RolemasterOpenEndedHigh or RollDieKinds.RolemasterOpenEndedLowSubtract;
RollDieKinds.RolemasterOpenEndedInitial or
RollDieKinds.RolemasterOpenEndedHigh or
RollDieKinds.RolemasterOpenEndedLowSubtract;
} }
private static string RollDieTitle(RollDieResult die) private static string RollDieTitle(RollDieResult die)
@@ -132,4 +129,4 @@ public partial class RollDiceStrip
[Parameter] [Parameter]
public string AriaLabel { get; set; } = "Rolled dice"; public string AriaLabel { get; set; } = "Rolled dice";
} }

View File

@@ -27,9 +27,7 @@ internal static class RulesetFormHelpers
public static bool IsRolemasterOpenEndedExpression(string? expression) public static bool IsRolemasterOpenEndedExpression(string? expression)
{ {
var parseResult = TryParseRolemasterExpression(expression); var parseResult = TryParseRolemasterExpression(expression);
return parseResult.Succeeded && return parseResult.Succeeded && parseResult.Value is not null && parseResult.Value.Kind == DiceExpressionKind.RolemasterOpenEndedPercentile;
parseResult.Value is not null &&
parseResult.Value.Kind == DiceExpressionKind.RolemasterOpenEndedPercentile;
} }
public static string DescribeRolemasterExpression(string expression, int? fumbleRange) public static string DescribeRolemasterExpression(string expression, int? fumbleRange)
@@ -40,10 +38,8 @@ internal static class RulesetFormHelpers
return parseResult.Value.Kind switch return parseResult.Value.Kind switch
{ {
DiceExpressionKind.RolemasterOpenEndedPercentile => fumbleRange.HasValue DiceExpressionKind.RolemasterOpenEndedPercentile => fumbleRange.HasValue ? $"Open-ended percentile: {parseResult.Value.Canonical}, fumble <= {fumbleRange.Value}" : $"Open-ended percentile: {parseResult.Value.Canonical}",
? $"Open-ended percentile: {parseResult.Value.Canonical}, fumble <= {fumbleRange.Value}" _ => $"Rolemaster: {parseResult.Value.Canonical}"
: $"Open-ended percentile: {parseResult.Value.Canonical}",
_ => $"Rolemaster: {parseResult.Value.Canonical}"
}; };
} }
@@ -59,4 +55,4 @@ internal static class RulesetFormHelpers
return DiceRules.ParseExpression(RulesetKind.Rolemaster, expression); return DiceRules.ParseExpression(RulesetKind.Rolemaster, expression);
} }
} }

View File

@@ -65,4 +65,4 @@
</form> </form>
</section> </section>
</div> </div>
} }

View File

@@ -1,6 +1,5 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using RpgRoller.Contracts; using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls; namespace RpgRoller.Components.Pages.HomeControls;
@@ -54,9 +53,7 @@ public partial class SkillFormModal
FormState.Errors["fumbleRange"] = "Open-ended Rolemaster skills require a fumble range."; FormState.Errors["fumbleRange"] = "Open-ended Rolemaster skills require a fumble range.";
} }
else else
{
FormState.Model.FumbleRange = null; FormState.Model.FumbleRange = null;
}
if (!IsD6Ruleset) if (!IsD6Ruleset)
{ {
@@ -84,9 +81,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, FormState.Model.FumbleRange)); 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
{ {
if (!SelectedCharacterId.HasValue) if (!SelectedCharacterId.HasValue)
@@ -117,13 +112,6 @@ public partial class SkillFormModal
NormalizeRolemasterFumbleRange(); NormalizeRolemasterFumbleRange();
} }
private bool IsD6Ruleset => RulesetFormHelpers.IsD6(RulesetId);
private bool IsRolemasterRuleset => RulesetFormHelpers.IsRolemaster(RulesetId);
private bool IsRolemasterOpenEndedSelected => RulesetFormHelpers.IsRolemasterOpenEndedExpression(FormState.Model.DiceRollDefinition);
private string ExpressionHelpText => IsRolemasterRuleset
? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed."
: "Enter the dice expression used for this skill.";
private void SynchronizeRulesetSpecificFields() private void SynchronizeRulesetSpecificFields()
{ {
if (!IsRolemasterRuleset) if (!IsRolemasterRuleset)
@@ -149,6 +137,12 @@ public partial class SkillFormModal
FormState.Model.FumbleRange = null; FormState.Model.FumbleRange = null;
} }
private bool IsD6Ruleset => RulesetFormHelpers.IsD6(RulesetId);
private bool IsRolemasterRuleset => RulesetFormHelpers.IsRolemaster(RulesetId);
private bool IsRolemasterOpenEndedSelected => RulesetFormHelpers.IsRolemasterOpenEndedExpression(FormState.Model.DiceRollDefinition);
private string ExpressionHelpText => IsRolemasterRuleset ? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed." : "Enter the dice expression used for this skill.";
[Inject] [Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!; private RpgRollerApiClient ApiClient { get; set; } = null!;
@@ -214,4 +208,4 @@ public partial class SkillFormModal
[Parameter] [Parameter]
public EventCallback CancelRequested { get; set; } public EventCallback CancelRequested { get; set; }
} }

View File

@@ -77,4 +77,4 @@
<span>Add skill</span> <span>Add skill</span>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -57,4 +57,4 @@ public partial class SkillGroupBlock
[Parameter] [Parameter]
public EventCallback<Guid> DeleteGroupRequested { get; set; } public EventCallback<Guid> DeleteGroupRequested { get; set; }
} }

View File

@@ -73,13 +73,15 @@
GetRollDetailError="Play.GetRollDetailError" GetRollDetailError="Play.GetRollDetailError"
CustomRollCreated="Play.OnCustomRollCreatedAsync" CustomRollCreated="Play.OnCustomRollCreatedAsync"
ErrorOccurred="Play.OnCampaignLogPanelErrorAsync"/> ErrorOccurred="Play.OnCampaignLogPanelErrorAsync"/>
</main> </main>
<nav class="mobile-bottom-nav" aria-label="Play panel selector"> <nav class="mobile-bottom-nav" aria-label="Play panel selector">
<button type="button" class="switch @(State.MobilePanel == "character" ? "active" : string.Empty)" <button type="button" class="switch @(State.MobilePanel == "character" ? "active" : string.Empty)"
@onclick='() => Scope.SetMobilePanelAsync("character")'>Character @onclick='() => Scope.SetMobilePanelAsync("character")'>
Character
</button> </button>
<button type="button" class="switch @(State.MobilePanel == "log" ? "active" : string.Empty)" <button type="button" class="switch @(State.MobilePanel == "log" ? "active" : string.Empty)"
@onclick='() => Scope.SetMobilePanelAsync("log")'>Log @onclick='() => Scope.SetMobilePanelAsync("log")'>
Log
</button> </button>
</nav> </nav>
} }
@@ -214,4 +216,4 @@
AllowOwnerEdit="State.CanEditCharacterOwner" AllowOwnerEdit="State.CanEditCharacterOwner"
AvailableUsernames="State.KnownUsernames" AvailableUsernames="State.KnownUsernames"
CharacterSaved="Campaigns.OnCharacterUpdatedAsync" CharacterSaved="Campaigns.OnCharacterUpdatedAsync"
CancelRequested="Campaigns.CloseCharacterModals"/> CancelRequested="Campaigns.CloseCharacterModals"/>

View File

@@ -20,10 +20,16 @@ public partial class Workspace : IAsyncDisposable
} }
[JSInvokable] [JSInvokable]
public Task OnStateEventReceived(CampaignStateSnapshot state) => Live.OnStateEventReceivedAsync(state); public Task OnStateEventReceived(CampaignStateSnapshot state)
{
return Live.OnStateEventReceivedAsync(state);
}
[JSInvokable] [JSInvokable]
public Task OnConnectionStateChanged(string state) => Live.OnConnectionStateChangedAsync(state); public Task OnConnectionStateChanged(string state)
{
return Live.OnConnectionStateChangedAsync(state);
}
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
@@ -31,13 +37,25 @@ public partial class Workspace : IAsyncDisposable
DotNetRef?.Dispose(); DotNetRef?.Dispose();
} }
private bool CanEditCharacter(CharacterSummary character) => Campaigns.CanEditCharacter(character); private bool CanEditCharacter(CharacterSummary character)
{
return Campaigns.CanEditCharacter(character);
}
private void ClearAuthenticatedState() => Session.ClearAuthenticatedState(); private void ClearAuthenticatedState()
{
Session.ClearAuthenticatedState();
}
private Task EnsureAdminUsersLoadedAsync() => Admin.EnsureAdminUsersLoadedAsync(); private Task EnsureAdminUsersLoadedAsync()
{
return Admin.EnsureAdminUsersLoadedAsync();
}
private Task StopStateEventsAsync() => Live.StopStateEventsAsync(); private Task StopStateEventsAsync()
{
return Live.StopStateEventsAsync();
}
private async Task StartStateEventsCoreAsync(Guid campaignId) private async Task StartStateEventsCoreAsync(Guid campaignId)
{ {
@@ -86,76 +104,19 @@ public partial class Workspace : IAsyncDisposable
private WorkspaceState State { get; } = new(); private WorkspaceState State { get; } = new();
private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new( private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new(State, Feedback, JS, WorkspaceQuery, Play.EnsureSelectedCharacterActiveAsync, Play.RefreshSelectedCharacterSheetAsync, Play.RefreshCampaignLogAsync, Play.ResetCampaignLogDetailState, Play.ResetCampaignStateTracking, ClearAuthenticatedState, StopStateEventsAsync, message => LoggedOut.InvokeAsync(message));
State,
Feedback,
JS,
WorkspaceQuery,
Play.EnsureSelectedCharacterActiveAsync,
Play.RefreshSelectedCharacterSheetAsync,
Play.RefreshCampaignLogAsync,
Play.ResetCampaignLogDetailState,
Play.ResetCampaignStateTracking,
ClearAuthenticatedState,
StopStateEventsAsync,
message => LoggedOut.InvokeAsync(message));
private WorkspaceLiveStateController Live => m_Live ??= new( private WorkspaceLiveStateController Live => m_Live ??= new(State, Feedback, StartStateEventsCoreAsync, StopStateEventsCoreAsync, Scope.RefreshCampaignRosterAsync, Play.RefreshSelectedCharacterSheetAsync, Play.RefreshCampaignLogAsync, () => InvokeAsync(StateHasChanged));
State,
Feedback,
StartStateEventsCoreAsync,
StopStateEventsCoreAsync,
Scope.RefreshCampaignRosterAsync,
Play.RefreshSelectedCharacterSheetAsync,
Play.RefreshCampaignLogAsync,
() => InvokeAsync(StateHasChanged));
private WorkspacePlayCoordinator Play => m_Play ??= new( private WorkspacePlayCoordinator Play => m_Play ??= new(State, Feedback, ApiClient, WorkspaceQuery, CanEditCharacter, () => InvokeAsync(StateHasChanged));
State,
Feedback,
ApiClient,
WorkspaceQuery,
CanEditCharacter,
() => InvokeAsync(StateHasChanged));
private WorkspaceCampaignCoordinator Campaigns => m_Campaigns ??= new( private WorkspaceCampaignCoordinator Campaigns => m_Campaigns ??= new(State, Feedback, JS, ApiClient, Session.LoadKnownUsernamesAsync, Scope.ReloadCampaignsAsync, Scope.ReloadCharacterCampaignOptionsAsync, Scope.RefreshCampaignScopeAsync, Live.SyncStateEventsAsync);
State,
Feedback,
JS,
ApiClient,
Session.LoadKnownUsernamesAsync,
Scope.ReloadCampaignsAsync,
Scope.ReloadCharacterCampaignOptionsAsync,
Scope.RefreshCampaignScopeAsync,
Live.SyncStateEventsAsync);
private WorkspaceAdminCoordinator Admin => m_Admin ??= new( private WorkspaceAdminCoordinator Admin => m_Admin ??= new(State, Feedback, JS, ApiClient, WorkspaceQuery, ClearAuthenticatedState, StopStateEventsAsync, message => LoggedOut.InvokeAsync(message));
State,
Feedback,
JS,
ApiClient,
WorkspaceQuery,
ClearAuthenticatedState,
StopStateEventsAsync,
message => LoggedOut.InvokeAsync(message));
private WorkspaceFeedbackService Feedback => m_Feedback ??= new(State, () => InvokeAsync(StateHasChanged)); private WorkspaceFeedbackService Feedback => m_Feedback ??= new(State, () => InvokeAsync(StateHasChanged));
private WorkspaceSessionCoordinator Session => m_Session ??= new( private WorkspaceSessionCoordinator Session => m_Session ??= new(State, Feedback, JS, ApiClient, WorkspaceQuery, Scope.ReloadCampaignsAsync, Scope.ReloadCharacterCampaignOptionsAsync, Scope.RefreshCampaignScopeAsync, Live.SyncStateEventsAsync, Live.StopStateEventsAsync, EnsureAdminUsersLoadedAsync, Play.ResetCampaignLogDetailState, () => InvokeAsync(StateHasChanged), message => LoggedOut.InvokeAsync(message));
State,
Feedback,
JS,
ApiClient,
WorkspaceQuery,
Scope.ReloadCampaignsAsync,
Scope.ReloadCharacterCampaignOptionsAsync,
Scope.RefreshCampaignScopeAsync,
Live.SyncStateEventsAsync,
Live.StopStateEventsAsync,
EnsureAdminUsersLoadedAsync,
Play.ResetCampaignLogDetailState,
() => InvokeAsync(StateHasChanged),
message => LoggedOut.InvokeAsync(message));
private IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems private IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems
{ {
@@ -163,12 +124,27 @@ public partial class Workspace : IAsyncDisposable
{ {
var items = new List<AppHeaderMenuItem> var items = new List<AppHeaderMenuItem>
{ {
new() { Label = "Play", IsActive = State.IsPlayScreen, OnSelected = () => Session.SwitchScreenAsync("play") }, new()
new() { Label = "Campaign Management", IsActive = State.IsManagementScreen, OnSelected = () => Session.SwitchScreenAsync("management") } {
Label = "Play",
IsActive = State.IsPlayScreen,
OnSelected = () => Session.SwitchScreenAsync("play")
},
new()
{
Label = "Campaign Management",
IsActive = State.IsManagementScreen,
OnSelected = () => Session.SwitchScreenAsync("management")
}
}; };
if (State.IsCurrentUserAdmin) if (State.IsCurrentUserAdmin)
items.Add(new AppHeaderMenuItem { Label = "Admin", IsActive = State.IsAdminScreen, OnSelected = () => Session.SwitchScreenAsync(ScreenAdmin) }); items.Add(new()
{
Label = "Admin",
IsActive = State.IsAdminScreen,
OnSelected = () => Session.SwitchScreenAsync(ScreenAdmin)
});
return items; return items;
} }
@@ -178,12 +154,12 @@ public partial class Workspace : IAsyncDisposable
private DotNetObjectReference<Workspace>? DotNetRef { get; set; } private DotNetObjectReference<Workspace>? DotNetRef { get; set; }
private const string ScreenAdmin = "admin"; private const string ScreenAdmin = "admin";
private WorkspaceAdminCoordinator? m_Admin;
private WorkspaceCampaignScopeCoordinator? m_Scope; private WorkspaceCampaignCoordinator? m_Campaigns;
private WorkspaceFeedbackService? m_Feedback;
private WorkspaceLiveStateController? m_Live; private WorkspaceLiveStateController? m_Live;
private WorkspacePlayCoordinator? m_Play; private WorkspacePlayCoordinator? m_Play;
private WorkspaceCampaignCoordinator? m_Campaigns;
private WorkspaceAdminCoordinator? m_Admin; private WorkspaceCampaignScopeCoordinator? m_Scope;
private WorkspaceFeedbackService? m_Feedback;
private WorkspaceSessionCoordinator? m_Session; private WorkspaceSessionCoordinator? m_Session;
} }

View File

@@ -8,15 +8,7 @@ namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public sealed class WorkspaceAdminCoordinator public sealed class WorkspaceAdminCoordinator
{ {
public WorkspaceAdminCoordinator( public WorkspaceAdminCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Action clearAuthenticatedState, Func<Task> stopStateEventsAsync, Func<string?, Task> onLoggedOutAsync)
WorkspaceState state,
WorkspaceFeedbackService feedback,
IJSRuntime js,
RpgRollerApiClient apiClient,
WorkspaceQueryService workspaceQuery,
Action clearAuthenticatedState,
Func<Task> stopStateEventsAsync,
Func<string?, Task> onLoggedOutAsync)
{ {
m_State = state; m_State = state;
m_Feedback = feedback; m_Feedback = feedback;
@@ -63,10 +55,7 @@ public sealed class WorkspaceAdminCoordinator
try try
{ {
IReadOnlyList<string> roles = HasAdminRole(user) ? Array.Empty<string>() : [UserRoles.Admin]; IReadOnlyList<string> roles = HasAdminRole(user) ? Array.Empty<string>() : [UserRoles.Admin];
_ = await m_ApiClient.RequestAsync<AdminUserSummary>( _ = await m_ApiClient.RequestAsync<AdminUserSummary>("PUT", $"/api/admin/users/{user.Id}/roles", new UpdateUserRolesRequest(roles));
"PUT",
$"/api/admin/users/{user.Id}/roles",
new UpdateUserRolesRequest(roles));
await ReloadAdminUsersAsync(); await ReloadAdminUsersAsync();
m_Feedback.SetStatus("User roles updated.", false); m_Feedback.SetStatus("User roles updated.", false);
@@ -109,9 +98,7 @@ public sealed class WorkspaceAdminCoordinator
private async Task ReloadAdminUsersAsync() private async Task ReloadAdminUsersAsync()
{ {
m_State.AdminUsers = (await m_WorkspaceQuery.GetAdminUsersAsync()) m_State.AdminUsers = (await m_WorkspaceQuery.GetAdminUsersAsync()).OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase).ToList();
.OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase)
.ToList();
m_State.HasLoadedAdminUsers = true; m_State.HasLoadedAdminUsers = true;
} }
@@ -129,4 +116,4 @@ public sealed class WorkspaceAdminCoordinator
private readonly WorkspaceState m_State; private readonly WorkspaceState m_State;
private readonly Func<Task> m_StopStateEventsAsync; private readonly Func<Task> m_StopStateEventsAsync;
private readonly WorkspaceQueryService m_WorkspaceQuery; private readonly WorkspaceQueryService m_WorkspaceQuery;
} }

View File

@@ -8,16 +8,7 @@ namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public sealed class WorkspaceCampaignCoordinator public sealed class WorkspaceCampaignCoordinator
{ {
public WorkspaceCampaignCoordinator( public WorkspaceCampaignCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, Func<Task> loadKnownUsernamesAsync, Func<Guid?, Task> reloadCampaignsAsync, Func<Task> reloadCharacterCampaignOptionsAsync, Func<Task> refreshCampaignScopeAsync, Func<Task> syncStateEventsAsync)
WorkspaceState state,
WorkspaceFeedbackService feedback,
IJSRuntime js,
RpgRollerApiClient apiClient,
Func<Task> loadKnownUsernamesAsync,
Func<Guid?, Task> reloadCampaignsAsync,
Func<Task> reloadCharacterCampaignOptionsAsync,
Func<Task> refreshCampaignScopeAsync,
Func<Task> syncStateEventsAsync)
{ {
m_State = state; m_State = state;
m_Feedback = feedback; m_Feedback = feedback;
@@ -179,15 +170,15 @@ public sealed class WorkspaceCampaignCoordinator
return m_State.User is not null && (character.OwnerUserId == m_State.User.Id || m_State.IsCurrentUserAdmin); return m_State.User is not null && (character.OwnerUserId == m_State.User.Id || m_State.IsCurrentUserAdmin);
} }
private const string CampaignSessionKey = "campaign";
private readonly RpgRollerApiClient m_ApiClient; private readonly RpgRollerApiClient m_ApiClient;
private readonly WorkspaceFeedbackService m_Feedback; private readonly WorkspaceFeedbackService m_Feedback;
private readonly IJSRuntime m_JS; private readonly IJSRuntime m_JS;
private readonly Func<Task> m_LoadKnownUsernamesAsync; private readonly Func<Task> m_LoadKnownUsernamesAsync;
private readonly Func<Task> m_ReloadCharacterCampaignOptionsAsync;
private readonly Func<Guid?, Task> m_ReloadCampaignsAsync;
private readonly Func<Task> m_RefreshCampaignScopeAsync; private readonly Func<Task> m_RefreshCampaignScopeAsync;
private readonly Func<Guid?, Task> m_ReloadCampaignsAsync;
private readonly Func<Task> m_ReloadCharacterCampaignOptionsAsync;
private readonly WorkspaceState m_State; private readonly WorkspaceState m_State;
private readonly Func<Task> m_SyncStateEventsAsync; private readonly Func<Task> m_SyncStateEventsAsync;
}
private const string CampaignSessionKey = "campaign";
}

View File

@@ -1,25 +1,12 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Microsoft.JSInterop; using Microsoft.JSInterop;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages; namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public sealed class WorkspaceCampaignScopeCoordinator public sealed class WorkspaceCampaignScopeCoordinator
{ {
public WorkspaceCampaignScopeCoordinator( public WorkspaceCampaignScopeCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, WorkspaceQueryService workspaceQuery, Func<Task> ensureSelectedCharacterActiveAsync, Func<Task> refreshSelectedCharacterSheetAsync, Func<Guid?, Task> refreshCampaignLogAsync, Action resetCampaignLogDetailState, Action resetCampaignStateTracking, Action clearAuthenticatedState, Func<Task> stopStateEventsAsync, Func<string?, Task> onLoggedOutAsync)
WorkspaceState state,
WorkspaceFeedbackService feedback,
IJSRuntime js,
WorkspaceQueryService workspaceQuery,
Func<Task> ensureSelectedCharacterActiveAsync,
Func<Task> refreshSelectedCharacterSheetAsync,
Func<Guid?, Task> refreshCampaignLogAsync,
Action resetCampaignLogDetailState,
Action resetCampaignStateTracking,
Action clearAuthenticatedState,
Func<Task> stopStateEventsAsync,
Func<string?, Task> onLoggedOutAsync)
{ {
m_State = state; m_State = state;
m_Feedback = feedback; m_Feedback = feedback;
@@ -147,6 +134,9 @@ public sealed class WorkspaceCampaignScopeCoordinator
m_State.SelectedCharacterId = m_State.SelectedCampaign.Characters[0].Id; m_State.SelectedCharacterId = m_State.SelectedCampaign.Characters[0].Id;
} }
private const string CampaignSessionKey = "campaign";
private const string MobilePanelSessionKey = "play-panel";
private readonly Action m_ClearAuthenticatedState; private readonly Action m_ClearAuthenticatedState;
private readonly Func<Task> m_EnsureSelectedCharacterActiveAsync; private readonly Func<Task> m_EnsureSelectedCharacterActiveAsync;
private readonly WorkspaceFeedbackService m_Feedback; private readonly WorkspaceFeedbackService m_Feedback;
@@ -159,7 +149,4 @@ public sealed class WorkspaceCampaignScopeCoordinator
private readonly WorkspaceState m_State; private readonly WorkspaceState m_State;
private readonly Func<Task> m_StopStateEventsAsync; private readonly Func<Task> m_StopStateEventsAsync;
private readonly WorkspaceQueryService m_WorkspaceQuery; private readonly WorkspaceQueryService m_WorkspaceQuery;
}
private const string CampaignSessionKey = "campaign";
private const string MobilePanelSessionKey = "play-panel";
}

View File

@@ -27,7 +27,7 @@ public sealed class WorkspaceFeedbackService
private void AddToast(string message, bool isError) private void AddToast(string message, bool isError)
{ {
var toastId = Guid.NewGuid(); var toastId = Guid.NewGuid();
m_State.Toasts.Add(new WorkspaceToast(toastId, message, isError)); m_State.Toasts.Add(new(toastId, message, isError));
_ = DismissToastLaterAsync(toastId); _ = DismissToastLaterAsync(toastId);
} }
@@ -47,8 +47,8 @@ public sealed class WorkspaceFeedbackService
} }
} }
private const int ToastDurationMs = 3200;
private readonly Func<Task> m_RequestRefreshAsync; private readonly Func<Task> m_RequestRefreshAsync;
private readonly WorkspaceState m_State; private readonly WorkspaceState m_State;
}
private const int ToastDurationMs = 3200;
}

View File

@@ -6,15 +6,7 @@ namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public sealed class WorkspaceLiveStateController public sealed class WorkspaceLiveStateController
{ {
public WorkspaceLiveStateController( public WorkspaceLiveStateController(WorkspaceState state, WorkspaceFeedbackService feedback, Func<Guid, Task> startStateEventsAsync, Func<Task> stopStateEventsCoreAsync, Func<Task> refreshCampaignRosterAsync, Func<Task> refreshSelectedCharacterSheetAsync, Func<Guid?, Task> refreshCampaignLogAsync, Func<Task> requestRefreshAsync)
WorkspaceState state,
WorkspaceFeedbackService feedback,
Func<Guid, Task> startStateEventsAsync,
Func<Task> stopStateEventsCoreAsync,
Func<Task> refreshCampaignRosterAsync,
Func<Task> refreshSelectedCharacterSheetAsync,
Func<Guid?, Task> refreshCampaignLogAsync,
Func<Task> requestRefreshAsync)
{ {
m_State = state; m_State = state;
m_Feedback = feedback; m_Feedback = feedback;
@@ -53,9 +45,7 @@ public sealed class WorkspaceLiveStateController
await m_RefreshCampaignRosterAsync(); await m_RefreshCampaignRosterAsync();
var selectedCharacterChanged = previousSelectedCharacterId != m_State.SelectedCharacterId; var selectedCharacterChanged = previousSelectedCharacterId != m_State.SelectedCharacterId;
var selectedCharacterVersionChanged = m_State.IsPlayScreen && var selectedCharacterVersionChanged = m_State.IsPlayScreen && !selectedCharacterChanged && GetCharacterVersion(state, m_State.SelectedCharacterId) != previousSelectedCharacterVersion;
!selectedCharacterChanged &&
GetCharacterVersion(state, m_State.SelectedCharacterId) != previousSelectedCharacterVersion;
if (m_State.IsPlayScreen && (selectedCharacterChanged || selectedCharacterVersionChanged)) if (m_State.IsPlayScreen && (selectedCharacterChanged || selectedCharacterVersionChanged))
await m_RefreshSelectedCharacterSheetAsync(); await m_RefreshSelectedCharacterSheetAsync();
@@ -116,9 +106,7 @@ public sealed class WorkspaceLiveStateController
if (!characterId.HasValue) if (!characterId.HasValue)
return 0; return 0;
return snapshot.CharacterVersions return snapshot.CharacterVersions.FirstOrDefault(version => version.CharacterId == characterId.Value)?.Version ?? 0;
.FirstOrDefault(version => version.CharacterId == characterId.Value)
?.Version ?? 0;
} }
private readonly WorkspaceFeedbackService m_Feedback; private readonly WorkspaceFeedbackService m_Feedback;
@@ -129,4 +117,4 @@ public sealed class WorkspaceLiveStateController
private readonly Func<Guid, Task> m_StartStateEventsAsync; private readonly Func<Guid, Task> m_StartStateEventsAsync;
private readonly WorkspaceState m_State; private readonly WorkspaceState m_State;
private readonly Func<Task> m_StopStateEventsCoreAsync; private readonly Func<Task> m_StopStateEventsCoreAsync;
} }

View File

@@ -6,13 +6,7 @@ namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public sealed class WorkspacePlayCoordinator public sealed class WorkspacePlayCoordinator
{ {
public WorkspacePlayCoordinator( public WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Func<CharacterSummary, bool> canEditCharacter, Func<Task> requestRefreshAsync)
WorkspaceState state,
WorkspaceFeedbackService feedback,
RpgRollerApiClient apiClient,
WorkspaceQueryService workspaceQuery,
Func<CharacterSummary, bool> canEditCharacter,
Func<Task> requestRefreshAsync)
{ {
m_State = state; m_State = state;
m_Feedback = feedback; m_Feedback = feedback;
@@ -36,9 +30,7 @@ public sealed class WorkspacePlayCoordinator
var page = await m_WorkspaceQuery.GetCampaignLogPageAsync(m_State.SelectedCampaignId.Value, afterRollId, CampaignLogWindowSize); var page = await m_WorkspaceQuery.GetCampaignLogPageAsync(m_State.SelectedCampaignId.Value, afterRollId, CampaignLogWindowSize);
Guid? newestRollId = null; Guid? newestRollId = null;
if (!afterRollId.HasValue || page.ResetRequired) if (!afterRollId.HasValue || page.ResetRequired)
{
m_State.CampaignLog = page.Entries.ToList(); m_State.CampaignLog = page.Entries.ToList();
}
else if (page.Entries.Length > 0) else if (page.Entries.Length > 0)
{ {
m_State.CampaignLog.AddRange(page.Entries); m_State.CampaignLog.AddRange(page.Entries);
@@ -47,14 +39,8 @@ public sealed class WorkspacePlayCoordinator
} }
var shouldAutoExpandNewest = afterRollId.HasValue && page.Entries.Length > 0; var shouldAutoExpandNewest = afterRollId.HasValue && page.Entries.Length > 0;
if (!shouldAutoExpandNewest && if (!shouldAutoExpandNewest && !afterRollId.HasValue && m_State.CurrentCampaignState is not null && previousLogCount == 0 && page.Entries.Length > 0)
!afterRollId.HasValue &&
m_State.CurrentCampaignState is not null &&
previousLogCount == 0 &&
page.Entries.Length > 0)
{
shouldAutoExpandNewest = true; shouldAutoExpandNewest = true;
}
if (shouldAutoExpandNewest) if (shouldAutoExpandNewest)
{ {
@@ -63,9 +49,7 @@ public sealed class WorkspacePlayCoordinator
m_State.FreshCampaignLogRollId = newestRollId; m_State.FreshCampaignLogRollId = newestRollId;
} }
else if (!afterRollId.HasValue) else if (!afterRollId.HasValue)
{
m_State.FreshCampaignLogRollId = null; m_State.FreshCampaignLogRollId = null;
}
m_State.CampaignLogCursor = page.Cursor ?? afterRollId; m_State.CampaignLogCursor = page.Cursor ?? afterRollId;
TrimCampaignLogDetails(); TrimCampaignLogDetails();
@@ -91,12 +75,8 @@ public sealed class WorkspacePlayCoordinator
} }
var sheet = await m_WorkspaceQuery.GetCharacterSheetAsync(m_State.SelectedCharacterId.Value); var sheet = await m_WorkspaceQuery.GetCharacterSheetAsync(m_State.SelectedCharacterId.Value);
m_State.SelectedCharacterSkillGroups = sheet.SkillGroups m_State.SelectedCharacterSkillGroups = sheet.SkillGroups.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase).ToList();
.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase) m_State.SelectedCharacterSkills = sheet.Skills.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList();
.ToList();
m_State.SelectedCharacterSkills = sheet.Skills
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
} }
public Task EnsureSelectedCharacterActiveAsync() public Task EnsureSelectedCharacterActiveAsync()
@@ -338,15 +318,15 @@ public sealed class WorkspacePlayCoordinator
private static CampaignRollDetail ToCampaignRollDetail(RollResult roll) private static CampaignRollDetail ToCampaignRollDetail(RollResult roll)
{ {
return new CampaignRollDetail(roll.RollId, roll.Breakdown, roll.Dice.ToArray()); return new(roll.RollId, roll.Breakdown, roll.Dice.ToArray());
} }
private const int CampaignLogWindowSize = 25;
private readonly RpgRollerApiClient m_ApiClient; private readonly RpgRollerApiClient m_ApiClient;
private readonly Func<CharacterSummary, bool> m_CanEditCharacter; private readonly Func<CharacterSummary, bool> m_CanEditCharacter;
private readonly WorkspaceFeedbackService m_Feedback; private readonly WorkspaceFeedbackService m_Feedback;
private readonly Func<Task> m_RequestRefreshAsync; private readonly Func<Task> m_RequestRefreshAsync;
private readonly WorkspaceState m_State; private readonly WorkspaceState m_State;
private readonly WorkspaceQueryService m_WorkspaceQuery; private readonly WorkspaceQueryService m_WorkspaceQuery;
}
private const int CampaignLogWindowSize = 25;
}

View File

@@ -5,21 +5,7 @@ namespace RpgRoller.Components.Pages;
public sealed class WorkspaceSessionCoordinator public sealed class WorkspaceSessionCoordinator
{ {
public WorkspaceSessionCoordinator( public WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Func<Guid?, Task> reloadCampaignsAsync, Func<Task> reloadCharacterCampaignOptionsAsync, Func<Task> refreshCampaignScopeAsync, Func<Task> syncStateEventsAsync, Func<Task> stopStateEventsAsync, Func<Task> ensureAdminUsersLoadedAsync, Action resetCampaignLogDetailState, Func<Task> requestRefreshAsync, Func<string?, Task> onLoggedOutAsync)
WorkspaceState state,
WorkspaceFeedbackService feedback,
IJSRuntime js,
RpgRollerApiClient apiClient,
WorkspaceQueryService workspaceQuery,
Func<Guid?, Task> reloadCampaignsAsync,
Func<Task> reloadCharacterCampaignOptionsAsync,
Func<Task> refreshCampaignScopeAsync,
Func<Task> syncStateEventsAsync,
Func<Task> stopStateEventsAsync,
Func<Task> ensureAdminUsersLoadedAsync,
Action resetCampaignLogDetailState,
Func<Task> requestRefreshAsync,
Func<string?, Task> onLoggedOutAsync)
{ {
m_State = state; m_State = state;
m_Feedback = feedback; m_Feedback = feedback;
@@ -278,21 +264,6 @@ public sealed class WorkspaceSessionCoordinator
return exception.Message.Contains("JavaScript interop calls cannot be issued", StringComparison.OrdinalIgnoreCase); return exception.Message.Contains("JavaScript interop calls cannot be issued", StringComparison.OrdinalIgnoreCase);
} }
private readonly RpgRollerApiClient m_ApiClient;
private readonly WorkspaceFeedbackService m_Feedback;
private readonly Func<Task> m_EnsureAdminUsersLoadedAsync;
private readonly IJSRuntime m_JS;
private readonly Func<string?, Task> m_OnLoggedOutAsync;
private readonly Func<Task> m_ReloadCharacterCampaignOptionsAsync;
private readonly Func<Guid?, Task> m_ReloadCampaignsAsync;
private readonly Action m_ResetCampaignLogDetailState;
private readonly Func<Task> m_RefreshCampaignScopeAsync;
private readonly Func<Task> m_RequestRefreshAsync;
private readonly WorkspaceState m_State;
private readonly Func<Task> m_StopStateEventsAsync;
private readonly Func<Task> m_SyncStateEventsAsync;
private readonly WorkspaceQueryService m_WorkspaceQuery;
private const string ScreenPlay = "play"; private const string ScreenPlay = "play";
private const string ScreenManagement = "management"; private const string ScreenManagement = "management";
private const string ScreenAdmin = "admin"; private const string ScreenAdmin = "admin";
@@ -300,4 +271,19 @@ public sealed class WorkspaceSessionCoordinator
private const string CampaignSessionKey = "campaign"; private const string CampaignSessionKey = "campaign";
private const string MobilePanelSessionKey = "play-panel"; private const string MobilePanelSessionKey = "play-panel";
private const string RollVisibilitySessionKey = "roll-visibility"; private const string RollVisibilitySessionKey = "roll-visibility";
}
private readonly RpgRollerApiClient m_ApiClient;
private readonly Func<Task> m_EnsureAdminUsersLoadedAsync;
private readonly WorkspaceFeedbackService m_Feedback;
private readonly IJSRuntime m_JS;
private readonly Func<string?, Task> m_OnLoggedOutAsync;
private readonly Func<Task> m_RefreshCampaignScopeAsync;
private readonly Func<Guid?, Task> m_ReloadCampaignsAsync;
private readonly Func<Task> m_ReloadCharacterCampaignOptionsAsync;
private readonly Func<Task> m_RequestRefreshAsync;
private readonly Action m_ResetCampaignLogDetailState;
private readonly WorkspaceState m_State;
private readonly Func<Task> m_StopStateEventsAsync;
private readonly Func<Task> m_SyncStateEventsAsync;
private readonly WorkspaceQueryService m_WorkspaceQuery;
}

View File

@@ -1,11 +1,41 @@
using RpgRoller.Components.Pages.HomeControls;
using RpgRoller.Contracts; using RpgRoller.Contracts;
using RpgRoller.Domain; using RpgRoller.Domain;
using RpgRoller.Components.Pages.HomeControls;
namespace RpgRoller.Components.Pages; namespace RpgRoller.Components.Pages;
public sealed class WorkspaceState public sealed class WorkspaceState
{ {
public string OwnerLabel(Guid ownerUserId)
{
if (User is not null && ownerUserId == User.Id)
return "You";
if (SelectedCampaign is null)
return "Unknown owner";
if (ownerUserId == SelectedCampaign.Gm.Id)
return $"{SelectedCampaign.Gm.DisplayName} (GM)";
var ownerDisplayName = SelectedCampaign.Characters.Where(character => character.OwnerUserId == ownerUserId).Select(character => character.OwnerDisplayName).FirstOrDefault(displayName => !string.IsNullOrWhiteSpace(displayName));
return string.IsNullOrWhiteSpace(ownerDisplayName) ? "Unknown owner" : ownerDisplayName;
}
public string SkillDefinitionLabel(CharacterSheetSkill skill)
{
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
{
if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase))
return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange);
return skill.DiceRollDefinition;
}
var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off";
return $"{skill.DiceRollDefinition}, wild {skill.WildDice}, {fumbleLabel}";
}
public UserSummary? User { get; set; } public UserSummary? User { get; set; }
public Guid? ActiveCharacterId { get; set; } public Guid? ActiveCharacterId { get; set; }
public Guid? SelectedCampaignId { get; set; } public Guid? SelectedCampaignId { get; set; }
@@ -66,18 +96,11 @@ public sealed class WorkspaceState
return null; return null;
if (User is null) if (User is null)
return new CampaignRoster(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, []); return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, []);
var ownedCharacters = SelectedCampaign.Characters var ownedCharacters = SelectedCampaign.Characters.Where(character => character.OwnerUserId == User.Id).ToArray();
.Where(character => character.OwnerUserId == User.Id)
.ToArray();
return new CampaignRoster( return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, ownedCharacters);
SelectedCampaign.Id,
SelectedCampaign.Name,
SelectedCampaign.RulesetId,
SelectedCampaign.Gm,
ownedCharacters);
} }
} }
@@ -148,37 +171,4 @@ public sealed class WorkspaceState
}; };
public string AppCssClass => IsPlayScreen ? "rr-app app-play" : "rr-app"; public string AppCssClass => IsPlayScreen ? "rr-app app-play" : "rr-app";
}
public string OwnerLabel(Guid ownerUserId)
{
if (User is not null && ownerUserId == User.Id)
return "You";
if (SelectedCampaign is null)
return "Unknown owner";
if (ownerUserId == SelectedCampaign.Gm.Id)
return $"{SelectedCampaign.Gm.DisplayName} (GM)";
var ownerDisplayName = SelectedCampaign.Characters
.Where(character => character.OwnerUserId == ownerUserId)
.Select(character => character.OwnerDisplayName)
.FirstOrDefault(displayName => !string.IsNullOrWhiteSpace(displayName));
return string.IsNullOrWhiteSpace(ownerDisplayName) ? "Unknown owner" : ownerDisplayName;
}
public string SkillDefinitionLabel(CharacterSheetSkill skill)
{
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
{
if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase))
return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange);
return skill.DiceRollDefinition;
}
var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off";
return $"{skill.DiceRollDefinition}, wild {skill.WildDice}, {fumbleLabel}";
}
}

View File

@@ -1,3 +1,3 @@
namespace RpgRoller.Components.Pages; namespace RpgRoller.Components.Pages;
public sealed record WorkspaceToast(Guid Id, string Message, bool IsError); public sealed record WorkspaceToast(Guid Id, string Message, bool IsError);

View File

@@ -6,4 +6,4 @@
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/> <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
<FocusOnNavigate RouteData="@routeData" Selector="h1"/> <FocusOnNavigate RouteData="@routeData" Selector="h1"/>
</Found> </Found>
</Router> </Router>

View File

@@ -53,4 +53,4 @@ public sealed class ApiRequestException : Exception
public int StatusCode { get; } public int StatusCode { get; }
public string? ErrorCode { get; } public string? ErrorCode { get; }
} }

View File

@@ -82,9 +82,9 @@ public sealed class WorkspaceQueryService
private static ApiRequestException ToApiRequestException(ServiceError error) private static ApiRequestException ToApiRequestException(ServiceError error)
{ {
var statusCode = error.Code == "unauthorized" ? 401 : 400; var statusCode = error.Code == "unauthorized" ? 401 : 400;
return new ApiRequestException(statusCode, error.Message, error.Code); return new(statusCode, error.Message, error.Code);
} }
private readonly IGameService m_GameService; private readonly IGameService m_GameService;
private readonly WorkspaceSessionTokenAccessor m_SessionTokenAccessor; private readonly WorkspaceSessionTokenAccessor m_SessionTokenAccessor;
} }

View File

@@ -10,9 +10,7 @@ public sealed class WorkspaceSessionTokenAccessor
if (httpContext is null) if (httpContext is null)
return; return;
if (httpContext.Items.TryGetValue(SessionTokenItemKey, out var storedToken) && if (httpContext.Items.TryGetValue(SessionTokenItemKey, out var storedToken) && storedToken is string sessionToken && !string.IsNullOrWhiteSpace(sessionToken))
storedToken is string sessionToken &&
!string.IsNullOrWhiteSpace(sessionToken))
{ {
m_SessionToken = sessionToken; m_SessionToken = sessionToken;
return; return;
@@ -32,4 +30,4 @@ public sealed class WorkspaceSessionTokenAccessor
private const string SessionTokenItemKey = "__rpgroller.session-token"; private const string SessionTokenItemKey = "__rpgroller.session-token";
private readonly string? m_SessionToken; private readonly string? m_SessionToken;
} }

View File

@@ -6,4 +6,4 @@
@using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using static Microsoft.AspNetCore.Components.Web.RenderMode @using static Microsoft.AspNetCore.Components.Web.RenderMode

View File

@@ -115,7 +115,8 @@ public sealed record CampaignLogListEntry(
string VisibilityStyle, string VisibilityStyle,
int Result, int Result,
string SummaryText, string SummaryText,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string[]? EventBadges, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
string[]? EventBadges,
DateTimeOffset TimestampUtc); DateTimeOffset TimestampUtc);
public sealed record CampaignRollDetail(Guid RollId, string Breakdown, RollDieResult[] Dice); public sealed record CampaignRollDetail(Guid RollId, string Breakdown, RollDieResult[] Dice);
@@ -124,4 +125,4 @@ public sealed record CharacterStateVersion(Guid CharacterId, long Version);
public sealed record CampaignStateSnapshot(Guid CampaignId, long TotalVersion, long RosterVersion, long LogVersion, IReadOnlyList<CharacterStateVersion> CharacterVersions); public sealed record CampaignStateSnapshot(Guid CampaignId, long TotalVersion, long RosterVersion, long LogVersion, IReadOnlyList<CharacterStateVersion> CharacterVersions);
public sealed record CampaignLogPage(CampaignLogListEntry[] Entries, Guid? Cursor, bool HasMore, bool ResetRequired); public sealed record CampaignLogPage(CampaignLogListEntry[] Entries, Guid? Cursor, bool HasMore, bool ResetRequired);

View File

@@ -16,4 +16,4 @@ public static class RpgRollerJson
if (!options.TypeInfoResolverChain.Contains(RpgRollerJsonSerializerContext.Default)) if (!options.TypeInfoResolverChain.Contains(RpgRollerJsonSerializerContext.Default))
options.TypeInfoResolverChain.Insert(0, RpgRollerJsonSerializerContext.Default); options.TypeInfoResolverChain.Insert(0, RpgRollerJsonSerializerContext.Default);
} }
} }

View File

@@ -56,4 +56,4 @@ namespace RpgRoller.Contracts;
[JsonSerializable(typeof(UserSummary))] [JsonSerializable(typeof(UserSummary))]
public partial class RpgRollerJsonSerializerContext : JsonSerializerContext public partial class RpgRollerJsonSerializerContext : JsonSerializerContext
{ {
} }

View File

@@ -91,4 +91,4 @@ public sealed class RpgRollerDbContext : DbContext
public DbSet<Skill> Skills => Set<Skill>(); public DbSet<Skill> Skills => Set<Skill>();
public DbSet<SkillGroup> SkillGroups => Set<SkillGroup>(); public DbSet<SkillGroup> SkillGroups => Set<SkillGroup>();
public DbSet<RollLogEntry> RollLogEntries => Set<RollLogEntry>(); public DbSet<RollLogEntry> RollLogEntries => Set<RollLogEntry>();
} }

View File

@@ -96,4 +96,4 @@ public enum DiceExpressionKind
RolemasterOpenEndedPercentile RolemasterOpenEndedPercentile
} }
public sealed record DiceExpression(int DiceCount, int Sides, int Modifier, string Canonical, DiceExpressionKind Kind = DiceExpressionKind.Standard); public sealed record DiceExpression(int DiceCount, int Sides, int Modifier, string Canonical, DiceExpressionKind Kind = DiceExpressionKind.Standard);

View File

@@ -40,4 +40,4 @@ public static class ServiceCollectionExtensions
if (!string.IsNullOrWhiteSpace(directory)) if (!string.IsNullOrWhiteSpace(directory))
Directory.CreateDirectory(directory); Directory.CreateDirectory(directory);
} }
} }

View File

@@ -1,3 +1,3 @@
namespace RpgRoller.Hosting; namespace RpgRoller.Hosting;
public sealed record SqliteDatabaseFile(string? Path); public sealed record SqliteDatabaseFile(string? Path);

View File

@@ -133,4 +133,4 @@ public static class SqliteSchemaUpgrader
private const string CharactersCampaignDeletionMigrationId = "20260226160859_AddAuthorizationRolesAndCampaignDeletion"; private const string CharactersCampaignDeletionMigrationId = "20260226160859_AddAuthorizationRolesAndCampaignDeletion";
private const string AuthorizationRolesMigrationId = "20260226170000_AddAuthorizationRoles"; private const string AuthorizationRolesMigrationId = "20260226170000_AddAuthorizationRoles";
private const string ProductVersion = "10.0.2"; private const string ProductVersion = "10.0.2";
} }

View File

@@ -41,4 +41,4 @@ app.MapStaticAssets();
app.MapRazorComponents<App>().AddInteractiveServerRenderMode(); app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
app.Run(); app.Run();
public partial class Program; public partial class Program;

View File

@@ -35,12 +35,7 @@ public static class CampaignLogSummaryBuilder
break; break;
case RulesetKind.Rolemaster: case RulesetKind.Rolemaster:
AddBadgeIfMissing( AddBadgeIfMissing(badges, dice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal) && !die.SignedContribution.HasValue), "rf");
badges,
dice.Any(die =>
string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal) &&
!die.SignedContribution.HasValue),
"rf");
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 100), "r100"); AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 100), "r100");
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 66), "r66"); AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 66), "r66");
break; break;
@@ -63,17 +58,11 @@ public static class CampaignLogSummaryBuilder
var openEndedInitial = dice.FirstOrDefault(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal)); var openEndedInitial = dice.FirstOrDefault(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal));
if (openEndedInitial is not null) if (openEndedInitial is not null)
{ {
var highFollowUps = dice var highFollowUps = dice.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedHigh, StringComparison.Ordinal)).Select(die => die.Roll.ToString()).ToArray();
.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedHigh, StringComparison.Ordinal))
.Select(die => die.Roll.ToString())
.ToArray();
if (highFollowUps.Length > 0) if (highFollowUps.Length > 0)
return $"{openEndedInitial.Roll} + {string.Join(" + ", highFollowUps)} | open-ended high"; return $"{openEndedInitial.Roll} + {string.Join(" + ", highFollowUps)} | open-ended high";
var lowFollowUps = dice var lowFollowUps = dice.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedLowSubtract, StringComparison.Ordinal)).Select(die => die.Roll.ToString()).ToArray();
.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedLowSubtract, StringComparison.Ordinal))
.Select(die => die.Roll.ToString())
.ToArray();
if (lowFollowUps.Length > 0) if (lowFollowUps.Length > 0)
return $"({RollBreakdownFormatter.FormatRolemasterTriggerRoll(openEndedInitial.Roll)}) {string.Join(" ", lowFollowUps.Select(roll => $"-{roll}"))} | open-ended low"; return $"({RollBreakdownFormatter.FormatRolemasterTriggerRoll(openEndedInitial.Roll)}) {string.Join(" ", lowFollowUps.Select(roll => $"-{roll}"))} | open-ended low";
@@ -91,10 +80,7 @@ public static class CampaignLogSummaryBuilder
private static bool IsRolemasterDieKind(string? kind) private static bool IsRolemasterDieKind(string? kind)
{ {
return kind is RollDieKinds.RolemasterStandard or return kind is RollDieKinds.RolemasterStandard or RollDieKinds.RolemasterOpenEndedInitial or RollDieKinds.RolemasterOpenEndedHigh or RollDieKinds.RolemasterOpenEndedLowSubtract;
RollDieKinds.RolemasterOpenEndedInitial or
RollDieKinds.RolemasterOpenEndedHigh or
RollDieKinds.RolemasterOpenEndedLowSubtract;
} }
private static void AddBadgeIfMissing(List<string> badges, bool condition, string code) private static void AddBadgeIfMissing(List<string> badges, bool condition, string code)
@@ -108,8 +94,6 @@ public static class CampaignLogSummaryBuilder
private static bool IsSingleD20Expression(string expression) private static bool IsSingleD20Expression(string expression)
{ {
var parsedExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, expression); var parsedExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, expression);
return parsedExpression.Succeeded && return parsedExpression.Succeeded && parsedExpression.Value!.DiceCount == 1 && parsedExpression.Value.Sides == 20;
parsedExpression.Value!.DiceCount == 1 &&
parsedExpression.Value.Sides == 20;
} }
} }

View File

@@ -4,9 +4,6 @@ namespace RpgRoller.Services;
public static class CustomRollOptionsResolver public static class CustomRollOptionsResolver
{ {
private const int DefaultCustomD6WildDice = 1;
private const bool DefaultCustomD6AllowFumble = true;
public static (int WildDice, bool AllowFumble, int? FumbleRange) Resolve(RulesetKind ruleset) public static (int WildDice, bool AllowFumble, int? FumbleRange) Resolve(RulesetKind ruleset)
{ {
return ruleset switch return ruleset switch
@@ -15,4 +12,7 @@ public static class CustomRollOptionsResolver
_ => (0, false, null) _ => (0, false, null)
}; };
} }
}
private const int DefaultCustomD6WildDice = 1;
private const bool DefaultCustomD6AllowFumble = true;
}

View File

@@ -91,4 +91,4 @@ public sealed class D6RollEngine
} }
private readonly IDiceRoller m_DiceRoller; private readonly IDiceRoller m_DiceRoller;
} }

View File

@@ -86,16 +86,14 @@ public static partial class DiceRules
var diceCount = string.IsNullOrEmpty(countValue) ? 1 : int.Parse(countValue); var diceCount = string.IsNullOrEmpty(countValue) ? 1 : int.Parse(countValue);
var sides = int.Parse(match.Groups["sides"].Value); var sides = int.Parse(match.Groups["sides"].Value);
var modifier = ParseModifier(match.Groups["modifier"].Value); var modifier = ParseModifier(match.Groups["modifier"].Value);
var validation = ValidateDiceParts(diceCount, sides, modifier, -MaxModifier, MaxModifier); var validation = ValidateDiceParts(diceCount, sides, modifier, -MaxModifier);
if (!validation.Succeeded) if (!validation.Succeeded)
return ServiceResult<DiceExpression>.Failure(validation.Error!.Code, validation.Error.Message); return ServiceResult<DiceExpression>.Failure(validation.Error!.Code, validation.Error.Message);
var isOpenEnded = match.Groups["openEnded"].Success; var isOpenEnded = match.Groups["openEnded"].Success;
if (isOpenEnded && (diceCount != 1 || sides != 100)) if (isOpenEnded && (diceCount != 1 || sides != 100))
{ {
return ServiceResult<DiceExpression>.Failure( return ServiceResult<DiceExpression>.Failure("invalid_expression", "Open-ended Rolemaster rolls must use d100! with an implicit or explicit dice count of 1.");
"invalid_expression",
"Open-ended Rolemaster rolls must use d100! with an implicit or explicit dice count of 1.");
} }
var countPrefix = diceCount == 1 ? string.Empty : diceCount.ToString(); var countPrefix = diceCount == 1 ? string.Empty : diceCount.ToString();
@@ -152,4 +150,4 @@ public static partial class DiceRules
(RulesetKind.Dnd5e, "dnd5e", "D&D 5e", "countdSides(+modifier), e.g. 2d12+2"), (RulesetKind.Dnd5e, "dnd5e", "D&D 5e", "countdSides(+modifier), e.g. 2d12+2"),
(RulesetKind.Rolemaster, "rolemaster", "Rolemaster", "countdSides(+/-modifier), e.g. d10, 15d10, d100-15, d100!+85") (RulesetKind.Rolemaster, "rolemaster", "Rolemaster", "countdSides(+/-modifier), e.g. d10, 15d10, d100-15, d100!+85")
]; ];
} }

View File

@@ -141,4 +141,4 @@ public sealed class GameAuthService
private readonly IPasswordHasher<UserAccount> m_PasswordHasher; private readonly IPasswordHasher<UserAccount> m_PasswordHasher;
private readonly GamePersistenceService m_PersistenceService; private readonly GamePersistenceService m_PersistenceService;
private readonly GameStateStore m_StateStore; private readonly GameStateStore m_StateStore;
} }

View File

@@ -18,9 +18,7 @@ public static class GameAuthorization
if (campaign.GmUserId == actorUserId) if (campaign.GmUserId == actorUserId)
return true; return true;
return stateStore.CharactersById.Values.Any(character => return stateStore.CharactersById.Values.Any(character => character.CampaignId == campaignId && character.OwnerUserId == actorUserId);
character.CampaignId == campaignId &&
character.OwnerUserId == actorUserId);
} }
public static bool CanEditCharacter(Guid actorUserId, Character character, Campaign campaign) public static bool CanEditCharacter(Guid actorUserId, Character character, Campaign campaign)
@@ -30,7 +28,6 @@ public static class GameAuthorization
public static bool CanViewRoll(GameStateStore stateStore, Guid actorUserId, Campaign campaign, RollLogEntry entry) public static bool CanViewRoll(GameStateStore stateStore, Guid actorUserId, Campaign campaign, RollLogEntry entry)
{ {
return CanViewCampaign(stateStore, actorUserId, campaign.Id) && return CanViewCampaign(stateStore, actorUserId, campaign.Id) && (entry.Visibility == RollVisibility.Public || entry.RollerUserId == actorUserId || campaign.GmUserId == actorUserId);
(entry.Visibility == RollVisibility.Public || entry.RollerUserId == actorUserId || campaign.GmUserId == actorUserId);
} }
} }

View File

@@ -49,11 +49,7 @@ public sealed class GameCampaignService
if (user is null) if (user is null)
return ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unauthorized", "You must be logged in."); return ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unauthorized", "You must be logged in.");
var results = m_StateStore.CampaignsById.Values var results = m_StateStore.CampaignsById.Values.Where(campaign => GameAuthorization.CanViewCampaign(m_StateStore, user.Id, campaign.Id)).OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).Select(campaign => GameDtoMapper.ToCampaignSummary(m_StateStore, campaign)).ToArray();
.Where(campaign => GameAuthorization.CanViewCampaign(m_StateStore, user.Id, campaign.Id))
.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase)
.Select(campaign => GameDtoMapper.ToCampaignSummary(m_StateStore, campaign))
.ToArray();
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success(results); return ServiceResult<IReadOnlyList<CampaignSummary>>.Success(results);
} }
@@ -67,10 +63,7 @@ public sealed class GameCampaignService
if (user is null) if (user is null)
return ServiceResult<IReadOnlyList<CampaignOption>>.Failure("unauthorized", "You must be logged in."); return ServiceResult<IReadOnlyList<CampaignOption>>.Failure("unauthorized", "You must be logged in.");
var options = m_StateStore.CampaignsById.Values var options = m_StateStore.CampaignsById.Values.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).Select(GameDtoMapper.ToCampaignOption).ToArray();
.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase)
.Select(GameDtoMapper.ToCampaignOption)
.ToArray();
return ServiceResult<IReadOnlyList<CampaignOption>>.Success(options); return ServiceResult<IReadOnlyList<CampaignOption>>.Success(options);
} }
@@ -124,4 +117,4 @@ public sealed class GameCampaignService
private readonly GamePersistenceService m_PersistenceService; private readonly GamePersistenceService m_PersistenceService;
private readonly GameStateStore m_StateStore; private readonly GameStateStore m_StateStore;
} }

View File

@@ -62,9 +62,7 @@ public sealed class GameCharacterService
var isOwner = character.OwnerUserId == user.Id; var isOwner = character.OwnerUserId == user.Id;
var isAdmin = GameAuthorization.HasRole(user, UserRoles.Admin); var isAdmin = GameAuthorization.HasRole(user, UserRoles.Admin);
var isSourceGm = character.CampaignId.HasValue && var isSourceGm = character.CampaignId.HasValue && m_StateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) && sourceCampaign.GmUserId == user.Id;
m_StateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) &&
sourceCampaign.GmUserId == user.Id;
var isTargetGm = targetCampaign is not null && targetCampaign.GmUserId == user.Id; var isTargetGm = targetCampaign is not null && targetCampaign.GmUserId == user.Id;
if (!isOwner && !isAdmin && !isSourceGm && !isTargetGm) if (!isOwner && !isAdmin && !isSourceGm && !isTargetGm)
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner, GM, or admin can edit this character."); return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner, GM, or admin can edit this character.");
@@ -85,12 +83,8 @@ public sealed class GameCharacterService
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the GM or admin can change character owner."); return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the GM or admin can change character owner.");
character.OwnerUserId = targetOwnerUserId; character.OwnerUserId = targetOwnerUserId;
if (character.OwnerUserId != previousOwnerUserId && if (character.OwnerUserId != previousOwnerUserId && m_StateStore.UsersById.TryGetValue(previousOwnerUserId, out var previousOwner) && previousOwner.ActiveCharacterId == character.Id)
m_StateStore.UsersById.TryGetValue(previousOwnerUserId, out var previousOwner) &&
previousOwner.ActiveCharacterId == character.Id)
{
previousOwner.ActiveCharacterId = null; previousOwner.ActiveCharacterId = null;
}
} }
if (sourceCampaignId != character.CampaignId) if (sourceCampaignId != character.CampaignId)
@@ -158,11 +152,7 @@ public sealed class GameCharacterService
if (user is null) if (user is null)
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("unauthorized", "You must be logged in."); return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("unauthorized", "You must be logged in.");
var characters = m_StateStore.CharactersById.Values var characters = m_StateStore.CharactersById.Values.Where(character => character.OwnerUserId == user.Id).OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase).Select(character => GameDtoMapper.ToCharacterSummary(m_StateStore, character)).ToArray();
.Where(character => character.OwnerUserId == user.Id)
.OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase)
.Select(character => GameDtoMapper.ToCharacterSummary(m_StateStore, character))
.ToArray();
return ServiceResult<IReadOnlyList<CharacterSummary>>.Success(characters); return ServiceResult<IReadOnlyList<CharacterSummary>>.Success(characters);
} }
@@ -200,4 +190,4 @@ public sealed class GameCharacterService
private readonly GamePersistenceService m_PersistenceService; private readonly GamePersistenceService m_PersistenceService;
private readonly GameStateStore m_StateStore; private readonly GameStateStore m_StateStore;
} }

View File

@@ -33,11 +33,9 @@ public static class GameContextResolver
public static bool TryResolveCharacterCampaignLocked(GameStateStore stateStore, Character character, out Campaign campaign, out ServiceError? error) public static bool TryResolveCharacterCampaignLocked(GameStateStore stateStore, Character character, out Campaign campaign, out ServiceError? error)
{ {
campaign = default!; campaign = default!;
if (!character.CampaignId.HasValue || if (!character.CampaignId.HasValue || !stateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var resolvedCampaign) || resolvedCampaign is null)
!stateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var resolvedCampaign) ||
resolvedCampaign is null)
{ {
error = new ServiceError("character_not_in_campaign", "Character is not linked to a campaign."); error = new("character_not_in_campaign", "Character is not linked to a campaign.");
return false; return false;
} }
@@ -45,4 +43,4 @@ public static class GameContextResolver
error = null; error = null;
return true; return true;
} }
} }

View File

@@ -24,19 +24,15 @@ public static class GameDtoMapper
{ {
var gm = stateStore.UsersById[campaign.GmUserId]; var gm = stateStore.UsersById[campaign.GmUserId];
var characterCount = stateStore.CharactersById.Values.Count(character => character.CampaignId == campaign.Id); var characterCount = stateStore.CharactersById.Values.Count(character => character.CampaignId == campaign.Id);
return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), new CampaignGmSummary(gm.Id, gm.DisplayName), characterCount); return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), new(gm.Id, gm.DisplayName), characterCount);
} }
public static CampaignRoster ToCampaignRoster(GameStateStore stateStore, Campaign campaign) public static CampaignRoster ToCampaignRoster(GameStateStore stateStore, Campaign campaign)
{ {
var gm = stateStore.UsersById[campaign.GmUserId]; var gm = stateStore.UsersById[campaign.GmUserId];
var characters = stateStore.CharactersById.Values var characters = stateStore.CharactersById.Values.Where(character => character.CampaignId == campaign.Id).OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase).Select(character => ToCharacterSummary(stateStore, character)).ToArray();
.Where(character => character.CampaignId == campaign.Id)
.OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase)
.Select(character => ToCharacterSummary(stateStore, character))
.ToArray();
return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), new CampaignGmSummary(gm.Id, gm.DisplayName), characters); return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), new(gm.Id, gm.DisplayName), characters);
} }
public static CharacterSummary ToCharacterSummary(GameStateStore stateStore, Character character) public static CharacterSummary ToCharacterSummary(GameStateStore stateStore, Character character)
@@ -46,16 +42,8 @@ public static class GameDtoMapper
public static CharacterSheet ToCharacterSheet(GameStateStore stateStore, Guid characterId) public static CharacterSheet ToCharacterSheet(GameStateStore stateStore, Guid characterId)
{ {
var skillGroups = stateStore.SkillGroupsById.Values var skillGroups = stateStore.SkillGroupsById.Values.Where(group => group.CharacterId == characterId).OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase).Select(ToCharacterSheetSkillGroup).ToArray();
.Where(group => group.CharacterId == characterId) var skills = stateStore.SkillsById.Values.Where(skill => skill.CharacterId == characterId).OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).Select(ToCharacterSheetSkill).ToArray();
.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToCharacterSheetSkillGroup)
.ToArray();
var skills = stateStore.SkillsById.Values
.Where(skill => skill.CharacterId == characterId)
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToCharacterSheetSkill)
.ToArray();
return new(characterId, skillGroups, skills); return new(characterId, skillGroups, skills);
} }
@@ -77,43 +65,12 @@ public static class GameDtoMapper
public static CampaignLogEntry ToCampaignLogEntry(RollLogEntry entry, string characterName, string skillName, string rollerDisplayName, IReadOnlyList<RollDieResult> dice) public static CampaignLogEntry ToCampaignLogEntry(RollLogEntry entry, string characterName, string skillName, string rollerDisplayName, IReadOnlyList<RollDieResult> dice)
{ {
return new( return new(entry.Id, entry.CampaignId, entry.CharacterId, characterName, entry.SkillId, skillName, entry.RollerUserId, rollerDisplayName, entry.Visibility == RollVisibility.Public ? "public" : "private", entry.Result, entry.Breakdown, dice, entry.TimestampUtc);
entry.Id,
entry.CampaignId,
entry.CharacterId,
characterName,
entry.SkillId,
skillName,
entry.RollerUserId,
rollerDisplayName,
entry.Visibility == RollVisibility.Public ? "public" : "private",
entry.Result,
entry.Breakdown,
dice,
entry.TimestampUtc);
} }
public static CampaignLogListEntry ToCampaignLogListEntry( public static CampaignLogListEntry ToCampaignLogListEntry(RollLogEntry entry, string characterName, string skillName, string rollerLabel, string visibilityLabel, string visibilityStyle, string summaryText, string[]? eventBadges)
RollLogEntry entry,
string characterName,
string skillName,
string rollerLabel,
string visibilityLabel,
string visibilityStyle,
string summaryText,
string[]? eventBadges)
{ {
return new( return new(entry.Id, characterName, skillName, rollerLabel, visibilityLabel, visibilityStyle, entry.Result, summaryText, eventBadges, entry.TimestampUtc);
entry.Id,
characterName,
skillName,
rollerLabel,
visibilityLabel,
visibilityStyle,
entry.Result,
summaryText,
eventBadges,
entry.TimestampUtc);
} }
public static CampaignRollDetail ToCampaignRollDetail(RollLogEntry entry, RollDieResult[] dice) public static CampaignRollDetail ToCampaignRollDetail(RollLogEntry entry, RollDieResult[] dice)
@@ -124,19 +81,14 @@ public static class GameDtoMapper
public static CampaignStateSnapshot ToCampaignStateSnapshot(GameStateStore stateStore, Guid campaignId) public static CampaignStateSnapshot ToCampaignStateSnapshot(GameStateStore stateStore, Guid campaignId)
{ {
var state = stateStore.GetOrCreateCampaignStateLocked(campaignId); var state = stateStore.GetOrCreateCampaignStateLocked(campaignId);
var characterVersions = state.CharacterVersions var characterVersions = state.CharacterVersions.OrderBy(version => version.Key).Select(version => new CharacterStateVersion(version.Key, version.Value)).ToArray();
.OrderBy(version => version.Key)
.Select(version => new CharacterStateVersion(version.Key, version.Value))
.ToArray();
return new CampaignStateSnapshot(campaignId, state.TotalVersion, state.RosterVersion, state.LogVersion, characterVersions); return new(campaignId, state.TotalVersion, state.RosterVersion, state.LogVersion, characterVersions);
} }
public static string ResolveOwnerDisplayName(GameStateStore stateStore, Guid ownerUserId, string fallback) public static string ResolveOwnerDisplayName(GameStateStore stateStore, Guid ownerUserId, string fallback)
{ {
return stateStore.UsersById.TryGetValue(ownerUserId, out var user) && !string.IsNullOrWhiteSpace(user.DisplayName) return stateStore.UsersById.TryGetValue(ownerUserId, out var user) && !string.IsNullOrWhiteSpace(user.DisplayName) ? user.DisplayName : fallback;
? user.DisplayName
: fallback;
} }
private static CharacterSheetSkillGroup ToCharacterSheetSkillGroup(SkillGroup skillGroup) private static CharacterSheetSkillGroup ToCharacterSheetSkillGroup(SkillGroup skillGroup)
@@ -148,4 +100,4 @@ public static class GameDtoMapper
{ {
return new(skill.Id, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange); return new(skill.Id, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange);
} }
} }

View File

@@ -106,4 +106,4 @@ public sealed class GamePersistenceService
private readonly IDbContextFactory<RpgRollerDbContext> m_DbContextFactory; private readonly IDbContextFactory<RpgRollerDbContext> m_DbContextFactory;
private readonly GameStateStore m_StateStore; private readonly GameStateStore m_StateStore;
} }

View File

@@ -11,10 +11,7 @@ public sealed class GameRollService
m_StateStore = stateStore; m_StateStore = stateStore;
m_PersistenceService = persistenceService; m_PersistenceService = persistenceService;
m_DiceRoller = diceRoller; m_DiceRoller = diceRoller;
m_RollEngine = new( m_RollEngine = new(new(diceRoller), new(diceRoller), new(diceRoller));
new StandardRollEngine(diceRoller),
new D6RollEngine(diceRoller),
new RolemasterRollEngine(diceRoller));
} }
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility) public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility)
@@ -88,10 +85,7 @@ public sealed class GameRollService
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Failure(context.Error!.Code, context.Error.Message); return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Failure(context.Error!.Code, context.Error.Message);
var (user, campaign) = context.Value!; var (user, campaign) = context.Value!;
var entries = GetVisibleCampaignLogEntriesLocked(user, campaign) var entries = GetVisibleCampaignLogEntriesLocked(user, campaign).TakeLast(CampaignLogHistoryWindowSize).Select(ToLogEntry).ToArray();
.TakeLast(CampaignLogHistoryWindowSize)
.Select(ToLogEntry)
.ToArray();
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Success(entries); return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Success(entries);
} }
@@ -112,28 +106,28 @@ public sealed class GameRollService
if (!afterRollId.HasValue) if (!afterRollId.HasValue)
{ {
var initialEntries = visibleEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray(); var initialEntries = visibleEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray();
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage(initialEntries, initialEntries.LastOrDefault()?.RollId, visibleEntries.Length > pageSize, false)); return ServiceResult<CampaignLogPage>.Success(new(initialEntries, initialEntries.LastOrDefault()?.RollId, visibleEntries.Length > pageSize, false));
} }
var afterIndex = Array.FindIndex(visibleEntries, entry => entry.Id == afterRollId.Value); var afterIndex = Array.FindIndex(visibleEntries, entry => entry.Id == afterRollId.Value);
if (afterIndex < 0) if (afterIndex < 0)
{ {
var replacementEntries = visibleEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray(); var replacementEntries = visibleEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray();
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage(replacementEntries, replacementEntries.LastOrDefault()?.RollId, visibleEntries.Length > pageSize, true)); return ServiceResult<CampaignLogPage>.Success(new(replacementEntries, replacementEntries.LastOrDefault()?.RollId, visibleEntries.Length > pageSize, true));
} }
var newEntries = visibleEntries.Skip(afterIndex + 1).ToArray(); var newEntries = visibleEntries.Skip(afterIndex + 1).ToArray();
if (newEntries.Length == 0) if (newEntries.Length == 0)
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage([], afterRollId, false, false)); return ServiceResult<CampaignLogPage>.Success(new([], afterRollId, false, false));
if (newEntries.Length > pageSize) if (newEntries.Length > pageSize)
{ {
var replacementEntries = newEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray(); var replacementEntries = newEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray();
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage(replacementEntries, replacementEntries[^1].RollId, true, true)); return ServiceResult<CampaignLogPage>.Success(new(replacementEntries, replacementEntries[^1].RollId, true, true));
} }
var appendedEntries = newEntries.Select(entry => ToLogListEntry(user, campaign, entry)).ToArray(); var appendedEntries = newEntries.Select(entry => ToLogListEntry(user, campaign, entry)).ToArray();
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage(appendedEntries, appendedEntries[^1].RollId, false, false)); return ServiceResult<CampaignLogPage>.Success(new(appendedEntries, appendedEntries[^1].RollId, false, false));
} }
} }
@@ -168,14 +162,7 @@ public sealed class GameRollService
} }
} }
private ServiceResult<RollResult> RecordRollLocked( private ServiceResult<RollResult> RecordRollLocked(UserAccount user, Campaign campaign, Character character, Guid skillId, RollVisibility visibility, (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) roll, string canonicalExpression)
UserAccount user,
Campaign campaign,
Character character,
Guid skillId,
RollVisibility visibility,
(int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) roll,
string canonicalExpression)
{ {
var entry = new RollLogEntry var entry = new RollLogEntry
{ {
@@ -200,18 +187,12 @@ public sealed class GameRollService
private static string FormatLoggedBreakdown(Guid skillId, string canonicalExpression, string breakdown) private static string FormatLoggedBreakdown(Guid skillId, string canonicalExpression, string breakdown)
{ {
return skillId == CustomRollSkillId return skillId == CustomRollSkillId ? $"{canonicalExpression}{CustomRollBreakdownSeparator}{breakdown}" : breakdown;
? $"{canonicalExpression}{CustomRollBreakdownSeparator}{breakdown}"
: breakdown;
} }
private IEnumerable<RollLogEntry> GetVisibleCampaignLogEntriesLocked(UserAccount user, Campaign campaign) private IEnumerable<RollLogEntry> GetVisibleCampaignLogEntriesLocked(UserAccount user, Campaign campaign)
{ {
return m_StateStore.RollLog return m_StateStore.RollLog.Where(r => r.CampaignId == campaign.Id).Where(r => r.Visibility == RollVisibility.Public || r.RollerUserId == user.Id || campaign.GmUserId == user.Id).OrderBy(r => r.TimestampUtc).ThenBy(r => r.Id);
.Where(r => r.CampaignId == campaign.Id)
.Where(r => r.Visibility == RollVisibility.Public || r.RollerUserId == user.Id || campaign.GmUserId == user.Id)
.OrderBy(r => r.TimestampUtc)
.ThenBy(r => r.Id);
} }
private CampaignLogEntry ToLogEntry(RollLogEntry entry) private CampaignLogEntry ToLogEntry(RollLogEntry entry)
@@ -232,15 +213,7 @@ public sealed class GameRollService
var loggedExpression = ResolveLoggedExpression(entry); var loggedExpression = ResolveLoggedExpression(entry);
var eventBadges = CampaignLogSummaryBuilder.BuildCompactLogEventBadges(campaign.Ruleset, loggedExpression, dice); var eventBadges = CampaignLogSummaryBuilder.BuildCompactLogEventBadges(campaign.Ruleset, loggedExpression, dice);
return GameDtoMapper.ToCampaignLogListEntry( return GameDtoMapper.ToCampaignLogListEntry(entry, characterName, skillName, ResolveLogRollerLabel(user, campaign, entry), ResolveLogVisibilityLabel(user, campaign, entry), ResolveLogVisibilityStyle(user, campaign, entry), CampaignLogSummaryBuilder.BuildCompactLogSummary(dice), eventBadges);
entry,
characterName,
skillName,
ResolveLogRollerLabel(user, campaign, entry),
ResolveLogVisibilityLabel(user, campaign, entry),
ResolveLogVisibilityStyle(user, campaign, entry),
CampaignLogSummaryBuilder.BuildCompactLogSummary(dice),
eventBadges);
} }
private static string SerializeDice(IReadOnlyList<RollDieResult> dice) private static string SerializeDice(IReadOnlyList<RollDieResult> dice)
@@ -323,11 +296,11 @@ public sealed class GameRollService
private const int CampaignLogHistoryWindowSize = 100; private const int CampaignLogHistoryWindowSize = 100;
private const int CampaignLogLivePageSize = 25; private const int CampaignLogLivePageSize = 25;
private const string CustomRollBreakdownSeparator = " => "; private const string CustomRollBreakdownSeparator = " => ";
private static readonly Guid CustomRollSkillId = Guid.Empty;
private const string CustomRollLabel = "Custom roll"; private const string CustomRollLabel = "Custom roll";
private static readonly Guid CustomRollSkillId = Guid.Empty;
private static readonly JsonSerializerOptions DiceJsonOptions = RpgRollerJson.CreateSerializerOptions(); private static readonly JsonSerializerOptions DiceJsonOptions = RpgRollerJson.CreateSerializerOptions();
private readonly IDiceRoller m_DiceRoller; private readonly IDiceRoller m_DiceRoller;
private readonly GamePersistenceService m_PersistenceService; private readonly GamePersistenceService m_PersistenceService;
private readonly RollEngine m_RollEngine; private readonly RollEngine m_RollEngine;
private readonly GameStateStore m_StateStore; private readonly GameStateStore m_StateStore;
} }

View File

@@ -20,7 +20,9 @@ public sealed class GameService : IGameService
m_UserAdministrationService = new(m_StateStore, m_PersistenceService); m_UserAdministrationService = new(m_StateStore, m_PersistenceService);
m_PersistenceService.LoadStateFromDatabase(); m_PersistenceService.LoadStateFromDatabase();
lock (m_StateStore.Gate) lock (m_StateStore.Gate)
{
m_StateStore.RebuildCampaignStateLocked(); m_StateStore.RebuildCampaignStateLocked();
}
} }
public IReadOnlyList<RulesetDefinition> GetRulesets() public IReadOnlyList<RulesetDefinition> GetRulesets()
@@ -188,12 +190,13 @@ public sealed class GameService : IGameService
return m_RollService.GetCampaignStateSnapshot(sessionToken, campaignId); return m_RollService.GetCampaignStateSnapshot(sessionToken, campaignId);
} }
private readonly GameAuthService m_AuthService;
private readonly GameCampaignService m_CampaignService; private readonly GameCampaignService m_CampaignService;
private readonly GameCharacterService m_CharacterService; private readonly GameCharacterService m_CharacterService;
private readonly GameAuthService m_AuthService;
private readonly GamePersistenceService m_PersistenceService; private readonly GamePersistenceService m_PersistenceService;
private readonly GameRollService m_RollService; private readonly GameRollService m_RollService;
private readonly GameSkillService m_SkillService; private readonly GameSkillService m_SkillService;
private readonly GameStateStore m_StateStore; private readonly GameStateStore m_StateStore;
private readonly GameUserAdministrationService m_UserAdministrationService; private readonly GameUserAdministrationService m_UserAdministrationService;
} }

View File

@@ -273,4 +273,4 @@ public sealed class GameSkillService
private readonly GamePersistenceService m_PersistenceService; private readonly GamePersistenceService m_PersistenceService;
private readonly GameStateStore m_StateStore; private readonly GameStateStore m_StateStore;
} }

View File

@@ -96,4 +96,4 @@ public static class GameStateCloneFactory
TimestampUtc = entry.TimestampUtc TimestampUtc = entry.TimestampUtc
}; };
} }
} }

View File

@@ -4,22 +4,11 @@ namespace RpgRoller.Services;
public sealed class GameStateStore public sealed class GameStateStore
{ {
public object Gate { get; } = new();
public Dictionary<Guid, Campaign> CampaignsById { get; } = [];
public Dictionary<Guid, GameCampaignStateTracker> CampaignStateById { get; } = [];
public Dictionary<Guid, Character> CharactersById { get; } = [];
public List<RollLogEntry> RollLog { get; } = [];
public Dictionary<string, UserSession> SessionsByToken { get; } = new(StringComparer.Ordinal);
public Dictionary<Guid, SkillGroup> SkillGroupsById { get; } = [];
public Dictionary<Guid, Skill> SkillsById { get; } = [];
public Dictionary<string, Guid> UserIdsByUsername { get; } = new(StringComparer.Ordinal);
public Dictionary<Guid, UserAccount> UsersById { get; } = [];
public GameCampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId) public GameCampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId)
{ {
if (!CampaignStateById.TryGetValue(campaignId, out var state)) if (!CampaignStateById.TryGetValue(campaignId, out var state))
{ {
state = new GameCampaignStateTracker(); state = new();
CampaignStateById[campaignId] = state; CampaignStateById[campaignId] = state;
} }
@@ -31,7 +20,7 @@ public sealed class GameStateStore
CampaignStateById.Clear(); CampaignStateById.Clear();
foreach (var campaignId in CampaignsById.Keys) foreach (var campaignId in CampaignsById.Keys)
CampaignStateById[campaignId] = new GameCampaignStateTracker(); CampaignStateById[campaignId] = new();
foreach (var character in CharactersById.Values.Where(character => character.CampaignId.HasValue)) foreach (var character in CharactersById.Values.Where(character => character.CampaignId.HasValue))
AddCharacterStateLocked(character.CampaignId, character.Id); AddCharacterStateLocked(character.CampaignId, character.Id);
@@ -83,6 +72,17 @@ public sealed class GameStateStore
state.TotalVersion += 1; state.TotalVersion += 1;
state.LogVersion += 1; state.LogVersion += 1;
} }
public object Gate { get; } = new();
public Dictionary<Guid, Campaign> CampaignsById { get; } = [];
public Dictionary<Guid, GameCampaignStateTracker> CampaignStateById { get; } = [];
public Dictionary<Guid, Character> CharactersById { get; } = [];
public List<RollLogEntry> RollLog { get; } = [];
public Dictionary<string, UserSession> SessionsByToken { get; } = new(StringComparer.Ordinal);
public Dictionary<Guid, SkillGroup> SkillGroupsById { get; } = [];
public Dictionary<Guid, Skill> SkillsById { get; } = [];
public Dictionary<string, Guid> UserIdsByUsername { get; } = new(StringComparer.Ordinal);
public Dictionary<Guid, UserAccount> UsersById { get; } = [];
} }
public sealed class GameCampaignStateTracker public sealed class GameCampaignStateTracker
@@ -91,4 +91,4 @@ public sealed class GameCampaignStateTracker
public long RosterVersion { get; set; } = 1; public long RosterVersion { get; set; } = 1;
public long LogVersion { get; set; } = 1; public long LogVersion { get; set; } = 1;
public Dictionary<Guid, long> CharacterVersions { get; } = []; public Dictionary<Guid, long> CharacterVersions { get; } = [];
} }

View File

@@ -19,10 +19,7 @@ public sealed class GameUserAdministrationService
if (user is null) if (user is null)
return ServiceResult<IReadOnlyList<string>>.Failure("unauthorized", "You must be logged in."); return ServiceResult<IReadOnlyList<string>>.Failure("unauthorized", "You must be logged in.");
var usernames = m_StateStore.UsersById.Values var usernames = m_StateStore.UsersById.Values.Select(account => account.Username).OrderBy(username => username, StringComparer.OrdinalIgnoreCase).ToArray();
.Select(account => account.Username)
.OrderBy(username => username, StringComparer.OrdinalIgnoreCase)
.ToArray();
return ServiceResult<IReadOnlyList<string>>.Success(usernames); return ServiceResult<IReadOnlyList<string>>.Success(usernames);
} }
@@ -39,10 +36,7 @@ public sealed class GameUserAdministrationService
if (!GameAuthorization.HasRole(user, UserRoles.Admin)) if (!GameAuthorization.HasRole(user, UserRoles.Admin))
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("forbidden", "Admin role is required."); return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("forbidden", "Admin role is required.");
var users = m_StateStore.UsersById.Values var users = m_StateStore.UsersById.Values.OrderBy(account => account.Username, StringComparer.OrdinalIgnoreCase).Select(GameDtoMapper.ToAdminUserSummary).ToArray();
.OrderBy(account => account.Username, StringComparer.OrdinalIgnoreCase)
.Select(GameDtoMapper.ToAdminUserSummary)
.ToArray();
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Success(users); return ServiceResult<IReadOnlyList<AdminUserSummary>>.Success(users);
} }
@@ -92,32 +86,20 @@ public sealed class GameUserAdministrationService
if (!m_StateStore.UsersById.TryGetValue(userId, out var targetUser)) if (!m_StateStore.UsersById.TryGetValue(userId, out var targetUser))
return ServiceResult<bool>.Failure("user_not_found", "User was not found."); return ServiceResult<bool>.Failure("user_not_found", "User was not found.");
var gmCampaignIds = m_StateStore.CampaignsById.Values var gmCampaignIds = m_StateStore.CampaignsById.Values.Where(campaign => campaign.GmUserId == targetUser.Id).Select(campaign => campaign.Id).ToArray();
.Where(campaign => campaign.GmUserId == targetUser.Id)
.Select(campaign => campaign.Id)
.ToArray();
var gmCampaignIdSet = gmCampaignIds.ToHashSet(); var gmCampaignIdSet = gmCampaignIds.ToHashSet();
var preservedCharacterIds = m_StateStore.CharactersById.Values var preservedCharacterIds = m_StateStore.CharactersById.Values.Where(character => character.CampaignId.HasValue && gmCampaignIdSet.Contains(character.CampaignId.Value)).Select(character => character.Id).ToHashSet();
.Where(character => character.CampaignId.HasValue && gmCampaignIdSet.Contains(character.CampaignId.Value))
.Select(character => character.Id)
.ToHashSet();
foreach (var campaignId in gmCampaignIds) foreach (var campaignId in gmCampaignIds)
DeleteCampaignLocked(campaignId); DeleteCampaignLocked(campaignId);
var ownedCharacterIds = m_StateStore.CharactersById.Values var ownedCharacterIds = m_StateStore.CharactersById.Values.Where(character => character.OwnerUserId == targetUser.Id && !preservedCharacterIds.Contains(character.Id)).Select(character => character.Id).ToArray();
.Where(character => character.OwnerUserId == targetUser.Id && !preservedCharacterIds.Contains(character.Id))
.Select(character => character.Id)
.ToArray();
foreach (var characterId in ownedCharacterIds) foreach (var characterId in ownedCharacterIds)
DeleteCharacterLocked(characterId); DeleteCharacterLocked(characterId);
m_StateStore.RollLog.RemoveAll(entry => entry.RollerUserId == targetUser.Id); m_StateStore.RollLog.RemoveAll(entry => entry.RollerUserId == targetUser.Id);
var staleSessions = m_StateStore.SessionsByToken.Values var staleSessions = m_StateStore.SessionsByToken.Values.Where(session => session.UserId == targetUser.Id).Select(session => session.Token).ToArray();
.Where(session => session.UserId == targetUser.Id)
.Select(session => session.Token)
.ToArray();
foreach (var token in staleSessions) foreach (var token in staleSessions)
m_StateStore.SessionsByToken.Remove(token); m_StateStore.SessionsByToken.Remove(token);
@@ -134,10 +116,7 @@ public sealed class GameUserAdministrationService
if (!m_StateStore.CampaignsById.Remove(campaignId)) if (!m_StateStore.CampaignsById.Remove(campaignId))
return; return;
var affectedCharacterIds = m_StateStore.CharactersById.Values var affectedCharacterIds = m_StateStore.CharactersById.Values.Where(character => character.CampaignId == campaignId).Select(character => character.Id).ToArray();
.Where(character => character.CampaignId == campaignId)
.Select(character => character.Id)
.ToArray();
foreach (var characterId in affectedCharacterIds) foreach (var characterId in affectedCharacterIds)
m_StateStore.CharactersById[characterId].CampaignId = null; m_StateStore.CharactersById[characterId].CampaignId = null;
@@ -153,17 +132,11 @@ public sealed class GameUserAdministrationService
var campaignId = character.CampaignId; var campaignId = character.CampaignId;
m_StateStore.CharactersById.Remove(characterId); m_StateStore.CharactersById.Remove(characterId);
var skillGroupIds = m_StateStore.SkillGroupsById.Values var skillGroupIds = m_StateStore.SkillGroupsById.Values.Where(group => group.CharacterId == characterId).Select(group => group.Id).ToHashSet();
.Where(group => group.CharacterId == characterId)
.Select(group => group.Id)
.ToHashSet();
foreach (var skillGroupId in skillGroupIds) foreach (var skillGroupId in skillGroupIds)
m_StateStore.SkillGroupsById.Remove(skillGroupId); m_StateStore.SkillGroupsById.Remove(skillGroupId);
var skillIds = m_StateStore.SkillsById.Values var skillIds = m_StateStore.SkillsById.Values.Where(skill => skill.CharacterId == characterId).Select(skill => skill.Id).ToHashSet();
.Where(skill => skill.CharacterId == characterId)
.Select(skill => skill.Id)
.ToHashSet();
foreach (var skillId in skillIds) foreach (var skillId in skillIds)
m_StateStore.SkillsById.Remove(skillId); m_StateStore.SkillsById.Remove(skillId);
@@ -178,4 +151,4 @@ public sealed class GameUserAdministrationService
private readonly GamePersistenceService m_PersistenceService; private readonly GamePersistenceService m_PersistenceService;
private readonly GameStateStore m_StateStore; private readonly GameStateStore m_StateStore;
} }

Some files were not shown because too many files have changed in this diff Show More