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
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.
- `RpgRoller.Tests/coverlet.runsettings` measures the full `RpgRoller` backend assembly.

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));
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);
var groupedAgainSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(ownerClient, $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true, renamedGroup.Id));

View File

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

View File

@@ -1,5 +1,5 @@
using Microsoft.Data.Sqlite;
using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
@@ -218,12 +218,8 @@ public sealed class HostingCoverageTests
using var db = new RpgRollerDbContext(options);
var migrator = db.GetService<IMigrator>();
var charactersScript = migrator.GenerateScript(
fromMigration: "20260226131003_AddSkillGroupPrototypes",
toMigration: "20260226160859_AddAuthorizationRolesAndCampaignDeletion");
var rolesScript = migrator.GenerateScript(
fromMigration: "20260226160859_AddAuthorizationRolesAndCampaignDeletion",
toMigration: "20260226170000_AddAuthorizationRoles");
var charactersScript = migrator.GenerateScript("20260226131003_AddSkillGroupPrototypes", "20260226160859_AddAuthorizationRolesAndCampaignDeletion");
var rolesScript = migrator.GenerateScript("20260226160859_AddAuthorizationRolesAndCampaignDeletion", "20260226170000_AddAuthorizationRoles");
Assert.Contains("""CREATE TABLE "ef_temp_Characters" (""", 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 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 ownerUserId;
@@ -427,10 +423,7 @@ public sealed class HostingCoverageTests
builder.Logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning);
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning);
builder.Logging.AddFilter("Microsoft.Hosting", LogLevel.Warning);
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:RpgRoller"] = $"Data Source={copiedDbPath}"
});
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["ConnectionStrings:RpgRoller"] = $"Data Source={copiedDbPath}" });
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
using var app = builder.Build();

View File

@@ -1,5 +1,4 @@
using System.Text.Json;
using RpgRoller.Contracts;
namespace RpgRoller.Tests;
@@ -133,7 +132,7 @@ public sealed class PayloadBudgetTests
[Fact]
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;
service.Register("gm-rm-detail-budget", "Password123", "GM");

View File

@@ -1,5 +1,3 @@
using RpgRoller.Domain;
namespace RpgRoller.Tests;
public sealed class ServiceAdminAndCampaignDeletionTests

View File

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

View File

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

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));
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);
var regroupedSkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, skill.Id, "Strike", "2D+1", 1, true, renamedGroup.Id));

View File

@@ -32,11 +32,48 @@ public sealed class ServiceStateInfrastructureTests
Roles = "admin",
ActiveCharacterId = Guid.NewGuid()
};
var session = new UserSession { Token = "token", UserId = user.Id, 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 session = new UserSession
{
Token = "token",
UserId = user.Id,
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
{
Id = Guid.NewGuid(),

View File

@@ -1,12 +1,182 @@
using Microsoft.AspNetCore.Http;
using RpgRoller.Components;
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Tests;
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]
public void SessionTokenAccessor_ReadsSessionCookieFromHttpContext()
{
@@ -27,7 +197,7 @@ public sealed class WorkspaceQueryServiceTests
GetCampaignsHandler = 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]
public async Task GetMeAsync_MapsUnauthorizedServiceResultToApiRequestException()
{
var service = new StubGameService
{
GetMeHandler = _ => ServiceResult<MeResponse>.Failure("unauthorized", "You must be logged in.")
};
var service = new StubGameService { GetMeHandler = _ => ServiceResult<MeResponse>.Failure("unauthorized", "You must be logged in.") };
var queryService = new WorkspaceQueryService(service, CreateSessionTokenAccessor("expired-session"));
var exception = await Assert.ThrowsAsync<ApiRequestException>(queryService.GetMeAsync);
@@ -57,49 +224,6 @@ public sealed class WorkspaceQueryServiceTests
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers.Cookie = $"rpgroller_session={sessionToken}";
return new WorkspaceSessionTokenAccessor(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();
return new(new HttpContextAccessor { HttpContext = httpContext });
}
}

View File

@@ -1,6 +1,4 @@
using RpgRoller.Components.Pages;
using RpgRoller.Contracts;
using RpgRoller.Domain;
namespace RpgRoller.Tests;
@@ -14,14 +12,9 @@ public sealed class WorkspaceStateTests
var otherOwnerId = Guid.NewGuid();
var state = new WorkspaceState
{
User = new UserSummary(userId, "user", "User", []),
SelectedCampaign = new CampaignRoster(
Guid.NewGuid(),
"Alpha",
"d6",
new CampaignGmSummary(gmId, "GM"),
[
new CharacterSummary(Guid.NewGuid(), "Scout", otherOwnerId, Guid.NewGuid(), "Other Owner")
User = new(userId, "user", "User", []),
SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(gmId, "GM"), [
new(Guid.NewGuid(), "Scout", otherOwnerId, Guid.NewGuid(), "Other Owner")
])
};
@@ -35,17 +28,14 @@ public sealed class WorkspaceStateTests
public void SkillDefinitionLabel_FormatsD6RolemasterAndDefaultRulesets()
{
var skill = new CharacterSheetSkill(Guid.NewGuid(), null, "Awareness", "d100!+15", 1, true, 5);
var state = new WorkspaceState
{
SelectedCampaign = new CampaignRoster(Guid.NewGuid(), "Alpha", "d6", new CampaignGmSummary(Guid.NewGuid(), "GM"), [])
};
var state = new WorkspaceState { SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), []) };
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));
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));
}
@@ -58,17 +48,12 @@ public sealed class WorkspaceStateTests
var otherCharacter = new CharacterSummary(Guid.NewGuid(), "Other", Guid.NewGuid(), Guid.NewGuid(), "Other");
var state = new WorkspaceState
{
User = new UserSummary(userId, "user", "User", []),
SelectedCampaign = new CampaignRoster(
Guid.NewGuid(),
"Alpha",
"d6",
new CampaignGmSummary(Guid.NewGuid(), "GM"),
[ownedCharacter, secondOwnedCharacter, otherCharacter]),
User = new(userId, "user", "User", []),
SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), [ownedCharacter, secondOwnedCharacter, otherCharacter]),
SelectedCharacterId = secondOwnedCharacter.Id,
ActiveCharacterId = ownedCharacter.Id,
SelectedCharacterSkills = [new CharacterSheetSkill(Guid.NewGuid(), null, "Stealth", "2D+1", 1, true, null)],
SelectedCharacterSkillGroups = [new CharacterSheetSkillGroup(Guid.NewGuid(), "Combat", "2D+1", 1, true, null)]
SelectedCharacterSkills = [new(Guid.NewGuid(), null, "Stealth", "2D+1", 1, true, null)],
SelectedCharacterSkillGroups = [new(Guid.NewGuid(), "Combat", "2D+1", 1, true, null)]
};
Assert.Equal(2, state.PlaySelectedCampaign!.Characters.Length);
@@ -89,8 +74,8 @@ public sealed class WorkspaceStateTests
var adminId = Guid.NewGuid();
var state = new WorkspaceState
{
User = new UserSummary(adminId, "admin", "Admin", [UserRoles.Admin]),
SelectedCampaign = new CampaignRoster(Guid.NewGuid(), "Alpha", "d6", new CampaignGmSummary(adminId, "Admin"), []),
User = new(adminId, "admin", "Admin", [UserRoles.Admin]),
SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(adminId, "Admin"), []),
CurrentScreen = "admin",
ConnectionState = "reconnecting"
};

View File

@@ -36,10 +36,10 @@ internal static class AdminEndpoints
return TypedResults.Unauthorized();
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))
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);
return TypedResults.File(stream, "application/octet-stream", Path.GetFileName(databaseFile.Path));

View File

@@ -1,4 +1,3 @@
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Api;

View File

@@ -13,9 +13,7 @@ internal static class StateEventEndpoints
var stateResult = game.GetCampaignStateSnapshot(sessionToken, campaignId);
if (!stateResult.Succeeded)
{
return stateResult.Error!.Code == "unauthorized"
? TypedResults.Unauthorized()
: TypedResults.BadRequest(new ApiError(stateResult.Error.Message, stateResult.Error.Code));
return stateResult.Error!.Code == "unauthorized" ? TypedResults.Unauthorized() : TypedResults.BadRequest(new ApiError(stateResult.Error.Message, stateResult.Error.Code));
}
context.Response.Headers.CacheControl = "no-cache";
@@ -60,11 +58,8 @@ internal static class StateEventEndpoints
private static Task WriteStateEventAsync(HttpResponse response, CampaignStateSnapshot snapshot)
{
var characterVersions = string.Join(
",",
snapshot.CharacterVersions.Select(version => $"{{\"characterId\":\"{version.CharacterId}\",\"version\":{version.Version}}}"));
var characterVersions = string.Join(",", snapshot.CharacterVersions.Select(version => $"{{\"characterId\":\"{version.CharacterId}\",\"version\":{version.Version}}}"));
return response.WriteAsync(
$"event: state\ndata: {{\"campaignId\":\"{snapshot.CampaignId}\",\"totalVersion\":{snapshot.TotalVersion},\"rosterVersion\":{snapshot.RosterVersion},\"logVersion\":{snapshot.LogVersion},\"characterVersions\":[{characterVersions}]}}\n\n");
return response.WriteAsync($"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>
@code {
[CascadingParameter]
private Microsoft.AspNetCore.Http.HttpContext? HttpContext { get; set; }
private HttpContext? HttpContext { get; set; }
private string BaseHref
{
@@ -36,4 +37,5 @@
return pathBase.EndsWith('/') ? pathBase : $"{pathBase}/";
}
}
}

View File

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

View File

@@ -3,11 +3,15 @@
<h1>@Title</h1>
@if (User is null)
{
<p class="header-identity"><strong>Loading user...</strong></p>
<p class="header-identity">
<strong>Loading user...</strong>
</p>
}
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)
{

View File

@@ -1,5 +1,7 @@
<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">
@if (IsCampaignDataLoading)
{
@@ -47,9 +49,12 @@
}
</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
title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time>
title="@entry.TimestampUtc.ToString("O")">
@entry.TimestampUtc.ToLocalTime().ToString("g")
</time>
</span>
</button>
@if (isExpanded)

View File

@@ -1,7 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using RpgRoller.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls;
@@ -9,6 +8,8 @@ namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage]
public partial class CampaignLogPanel
{
private sealed record EventBadgeView(string Label, string Tone);
protected override async Task OnAfterRenderAsync(bool firstRender)
{
var currentLastRollId = CampaignLog.LastOrDefault()?.RollId;
@@ -37,6 +38,96 @@ public partial class CampaignLogPanel
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]
private IJSRuntime JS { get; set; } = null!;
@@ -97,105 +188,6 @@ public partial class CampaignLogPanel
[Parameter]
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 string? CustomRollErrorMessage => CustomRollState.Errors.GetValueOrDefault("expression");
private bool IsCustomRollDisabled => IsCampaignDataLoading || IsMutating || IsSubmittingCustomRoll || !SelectedCharacterId.HasValue;
@@ -203,6 +195,7 @@ public partial class CampaignLogPanel
private string? CustomRollInputTitle => HasCustomRollError ? CustomRollErrorMessage : null;
private string CustomRollErrorElementId => "custom-roll-expression-error";
private string? CustomRollInputDescribedBy => HasCustomRollError ? CustomRollErrorElementId : null;
private string CustomRollPlaceholder => SelectedCampaignRulesetId.ToLowerInvariant() switch
{
RulesetFormHelpers.RulesetIds.D6 => "e.g. 5D+4",
@@ -210,17 +203,19 @@ public partial class CampaignLogPanel
RulesetFormHelpers.RulesetIds.Rolemaster => "e.g. d10, 15d10, d100!+85",
_ => "Enter a roll expression"
};
private string CustomRollStatusText => SelectedCharacterId.HasValue && !string.IsNullOrWhiteSpace(SelectedCharacterName)
? $"For {SelectedCharacterName} • {RollVisibilityLabel}"
: "Select a character to enable";
private string CustomRollStatusText => SelectedCharacterId.HasValue && !string.IsNullOrWhiteSpace(SelectedCharacterName) ? $"For {SelectedCharacterName} • {RollVisibilityLabel}" : "Select a character to enable";
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.Rolemaster => $"{RulesetFormHelpers.RolemasterExampleText()}. Logged for the selected character.",
_ => "Uses the selected campaign ruleset and current visibility."
};
private string RollVisibilityLabel => string.Equals(NormalizedRollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "Private" : "Public";
private string NormalizedRollVisibility => string.Equals(RollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public";
private string CustomRollExpression
{
get => CustomRollState.Model.Expression;

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));
}
else
{
character = await ApiClient.RequestAsync<CharacterSummary>("POST", "/api/characters", new CreateCharacterRequest(FormState.Model.Name.Trim(), campaignId!.Value));
}
await CharacterSaved.InvokeAsync(character.CampaignId);
}

View File

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

View File

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

View File

@@ -81,10 +81,7 @@ public partial class RollDiceStrip
private static bool IsRolemasterDie(RollDieResult die)
{
return die.Kind is RollDieKinds.RolemasterStandard or
RollDieKinds.RolemasterOpenEndedInitial or
RollDieKinds.RolemasterOpenEndedHigh or
RollDieKinds.RolemasterOpenEndedLowSubtract;
return die.Kind is RollDieKinds.RolemasterStandard or RollDieKinds.RolemasterOpenEndedInitial or RollDieKinds.RolemasterOpenEndedHigh or RollDieKinds.RolemasterOpenEndedLowSubtract;
}
private static string RollDieTitle(RollDieResult die)

View File

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

View File

@@ -1,6 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls;
@@ -54,9 +53,7 @@ public partial class SkillFormModal
FormState.Errors["fumbleRange"] = "Open-ended Rolemaster skills require a fumble range.";
}
else
{
FormState.Model.FumbleRange = null;
}
if (!IsD6Ruleset)
{
@@ -84,9 +81,7 @@ public partial class SkillFormModal
{
SkillSummary skill;
if (EditingSkillId.HasValue)
{
skill = await ApiClient.RequestAsync<SkillSummary>("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId, FormState.Model.FumbleRange));
}
else
{
if (!SelectedCharacterId.HasValue)
@@ -117,13 +112,6 @@ public partial class SkillFormModal
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()
{
if (!IsRolemasterRuleset)
@@ -149,6 +137,12 @@ public partial class SkillFormModal
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]
private RpgRollerApiClient ApiClient { get; set; } = null!;

View File

@@ -76,10 +76,12 @@
</main>
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
<button type="button" class="switch @(State.MobilePanel == "character" ? "active" : string.Empty)"
@onclick='() => Scope.SetMobilePanelAsync("character")'>Character
@onclick='() => Scope.SetMobilePanelAsync("character")'>
Character
</button>
<button type="button" class="switch @(State.MobilePanel == "log" ? "active" : string.Empty)"
@onclick='() => Scope.SetMobilePanelAsync("log")'>Log
@onclick='() => Scope.SetMobilePanelAsync("log")'>
Log
</button>
</nav>
}

View File

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

View File

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

View File

@@ -8,16 +8,7 @@ namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public sealed class 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)
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)
{
m_State = state;
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);
}
private const string CampaignSessionKey = "campaign";
private readonly RpgRollerApiClient m_ApiClient;
private readonly WorkspaceFeedbackService m_Feedback;
private readonly IJSRuntime m_JS;
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<Guid?, Task> m_ReloadCampaignsAsync;
private readonly Func<Task> m_ReloadCharacterCampaignOptionsAsync;
private readonly WorkspaceState m_State;
private readonly Func<Task> m_SyncStateEventsAsync;
private const string CampaignSessionKey = "campaign";
}

View File

@@ -1,25 +1,12 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.JSInterop;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public sealed class 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)
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)
{
m_State = state;
m_Feedback = feedback;
@@ -147,6 +134,9 @@ public sealed class WorkspaceCampaignScopeCoordinator
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 Func<Task> m_EnsureSelectedCharacterActiveAsync;
private readonly WorkspaceFeedbackService m_Feedback;
@@ -159,7 +149,4 @@ public sealed class WorkspaceCampaignScopeCoordinator
private readonly WorkspaceState m_State;
private readonly Func<Task> m_StopStateEventsAsync;
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)
{
var toastId = Guid.NewGuid();
m_State.Toasts.Add(new WorkspaceToast(toastId, message, isError));
m_State.Toasts.Add(new(toastId, message, isError));
_ = DismissToastLaterAsync(toastId);
}
@@ -47,8 +47,8 @@ public sealed class WorkspaceFeedbackService
}
}
private const int ToastDurationMs = 3200;
private readonly Func<Task> m_RequestRefreshAsync;
private readonly WorkspaceState m_State;
private const int ToastDurationMs = 3200;
}

View File

@@ -6,15 +6,7 @@ namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public sealed class 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)
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)
{
m_State = state;
m_Feedback = feedback;
@@ -53,9 +45,7 @@ public sealed class WorkspaceLiveStateController
await m_RefreshCampaignRosterAsync();
var selectedCharacterChanged = previousSelectedCharacterId != m_State.SelectedCharacterId;
var selectedCharacterVersionChanged = m_State.IsPlayScreen &&
!selectedCharacterChanged &&
GetCharacterVersion(state, m_State.SelectedCharacterId) != previousSelectedCharacterVersion;
var selectedCharacterVersionChanged = m_State.IsPlayScreen && !selectedCharacterChanged && GetCharacterVersion(state, m_State.SelectedCharacterId) != previousSelectedCharacterVersion;
if (m_State.IsPlayScreen && (selectedCharacterChanged || selectedCharacterVersionChanged))
await m_RefreshSelectedCharacterSheetAsync();
@@ -116,9 +106,7 @@ public sealed class WorkspaceLiveStateController
if (!characterId.HasValue)
return 0;
return snapshot.CharacterVersions
.FirstOrDefault(version => version.CharacterId == characterId.Value)
?.Version ?? 0;
return snapshot.CharacterVersions.FirstOrDefault(version => version.CharacterId == characterId.Value)?.Version ?? 0;
}
private readonly WorkspaceFeedbackService m_Feedback;

View File

@@ -6,13 +6,7 @@ namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public sealed class WorkspacePlayCoordinator
{
public WorkspacePlayCoordinator(
WorkspaceState state,
WorkspaceFeedbackService feedback,
RpgRollerApiClient apiClient,
WorkspaceQueryService workspaceQuery,
Func<CharacterSummary, bool> canEditCharacter,
Func<Task> requestRefreshAsync)
public WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Func<CharacterSummary, bool> canEditCharacter, Func<Task> requestRefreshAsync)
{
m_State = state;
m_Feedback = feedback;
@@ -36,9 +30,7 @@ public sealed class WorkspacePlayCoordinator
var page = await m_WorkspaceQuery.GetCampaignLogPageAsync(m_State.SelectedCampaignId.Value, afterRollId, CampaignLogWindowSize);
Guid? newestRollId = null;
if (!afterRollId.HasValue || page.ResetRequired)
{
m_State.CampaignLog = page.Entries.ToList();
}
else if (page.Entries.Length > 0)
{
m_State.CampaignLog.AddRange(page.Entries);
@@ -47,14 +39,8 @@ public sealed class WorkspacePlayCoordinator
}
var shouldAutoExpandNewest = afterRollId.HasValue && page.Entries.Length > 0;
if (!shouldAutoExpandNewest &&
!afterRollId.HasValue &&
m_State.CurrentCampaignState is not null &&
previousLogCount == 0 &&
page.Entries.Length > 0)
{
if (!shouldAutoExpandNewest && !afterRollId.HasValue && m_State.CurrentCampaignState is not null && previousLogCount == 0 && page.Entries.Length > 0)
shouldAutoExpandNewest = true;
}
if (shouldAutoExpandNewest)
{
@@ -63,9 +49,7 @@ public sealed class WorkspacePlayCoordinator
m_State.FreshCampaignLogRollId = newestRollId;
}
else if (!afterRollId.HasValue)
{
m_State.FreshCampaignLogRollId = null;
}
m_State.CampaignLogCursor = page.Cursor ?? afterRollId;
TrimCampaignLogDetails();
@@ -91,12 +75,8 @@ public sealed class WorkspacePlayCoordinator
}
var sheet = await m_WorkspaceQuery.GetCharacterSheetAsync(m_State.SelectedCharacterId.Value);
m_State.SelectedCharacterSkillGroups = sheet.SkillGroups
.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
m_State.SelectedCharacterSkills = sheet.Skills
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
m_State.SelectedCharacterSkillGroups = sheet.SkillGroups.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase).ToList();
m_State.SelectedCharacterSkills = sheet.Skills.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList();
}
public Task EnsureSelectedCharacterActiveAsync()
@@ -338,15 +318,15 @@ public sealed class WorkspacePlayCoordinator
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 Func<CharacterSummary, bool> m_CanEditCharacter;
private readonly WorkspaceFeedbackService m_Feedback;
private readonly Func<Task> m_RequestRefreshAsync;
private readonly WorkspaceState m_State;
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 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)
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)
{
m_State = state;
m_Feedback = feedback;
@@ -278,21 +264,6 @@ public sealed class WorkspaceSessionCoordinator
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 ScreenManagement = "management";
private const string ScreenAdmin = "admin";
@@ -300,4 +271,19 @@ public sealed class WorkspaceSessionCoordinator
private const string CampaignSessionKey = "campaign";
private const string MobilePanelSessionKey = "play-panel";
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.Domain;
using RpgRoller.Components.Pages.HomeControls;
namespace RpgRoller.Components.Pages;
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 Guid? ActiveCharacterId { get; set; }
public Guid? SelectedCampaignId { get; set; }
@@ -66,18 +96,11 @@ public sealed class WorkspaceState
return 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
.Where(character => character.OwnerUserId == User.Id)
.ToArray();
var ownedCharacters = SelectedCampaign.Characters.Where(character => character.OwnerUserId == User.Id).ToArray();
return new CampaignRoster(
SelectedCampaign.Id,
SelectedCampaign.Name,
SelectedCampaign.RulesetId,
SelectedCampaign.Gm,
ownedCharacters);
return new(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 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

@@ -82,7 +82,7 @@ public sealed class WorkspaceQueryService
private static ApiRequestException ToApiRequestException(ServiceError error)
{
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;

View File

@@ -10,9 +10,7 @@ public sealed class WorkspaceSessionTokenAccessor
if (httpContext is null)
return;
if (httpContext.Items.TryGetValue(SessionTokenItemKey, out var storedToken) &&
storedToken is string sessionToken &&
!string.IsNullOrWhiteSpace(sessionToken))
if (httpContext.Items.TryGetValue(SessionTokenItemKey, out var storedToken) && storedToken is string sessionToken && !string.IsNullOrWhiteSpace(sessionToken))
{
m_SessionToken = sessionToken;
return;

View File

@@ -115,7 +115,8 @@ public sealed record CampaignLogListEntry(
string VisibilityStyle,
int Result,
string SummaryText,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string[]? EventBadges,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
string[]? EventBadges,
DateTimeOffset TimestampUtc);
public sealed record CampaignRollDetail(Guid RollId, string Breakdown, RollDieResult[] Dice);

View File

@@ -35,12 +35,7 @@ public static class CampaignLogSummaryBuilder
break;
case RulesetKind.Rolemaster:
AddBadgeIfMissing(
badges,
dice.Any(die =>
string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal) &&
!die.SignedContribution.HasValue),
"rf");
AddBadgeIfMissing(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 == 66), "r66");
break;
@@ -63,17 +58,11 @@ public static class CampaignLogSummaryBuilder
var openEndedInitial = dice.FirstOrDefault(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal));
if (openEndedInitial is not null)
{
var highFollowUps = dice
.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedHigh, StringComparison.Ordinal))
.Select(die => die.Roll.ToString())
.ToArray();
var highFollowUps = dice.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedHigh, StringComparison.Ordinal)).Select(die => die.Roll.ToString()).ToArray();
if (highFollowUps.Length > 0)
return $"{openEndedInitial.Roll} + {string.Join(" + ", highFollowUps)} | open-ended high";
var lowFollowUps = dice
.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedLowSubtract, StringComparison.Ordinal))
.Select(die => die.Roll.ToString())
.ToArray();
var lowFollowUps = dice.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedLowSubtract, StringComparison.Ordinal)).Select(die => die.Roll.ToString()).ToArray();
if (lowFollowUps.Length > 0)
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)
{
return kind is RollDieKinds.RolemasterStandard or
RollDieKinds.RolemasterOpenEndedInitial or
RollDieKinds.RolemasterOpenEndedHigh or
RollDieKinds.RolemasterOpenEndedLowSubtract;
return kind is RollDieKinds.RolemasterStandard or RollDieKinds.RolemasterOpenEndedInitial or RollDieKinds.RolemasterOpenEndedHigh or RollDieKinds.RolemasterOpenEndedLowSubtract;
}
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)
{
var parsedExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, expression);
return parsedExpression.Succeeded &&
parsedExpression.Value!.DiceCount == 1 &&
parsedExpression.Value.Sides == 20;
return parsedExpression.Succeeded && parsedExpression.Value!.DiceCount == 1 && parsedExpression.Value.Sides == 20;
}
}

View File

@@ -4,9 +4,6 @@ namespace RpgRoller.Services;
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)
{
return ruleset switch
@@ -15,4 +12,7 @@ public static class CustomRollOptionsResolver
_ => (0, false, null)
};
}
private const int DefaultCustomD6WildDice = 1;
private const bool DefaultCustomD6AllowFumble = true;
}

View File

@@ -86,16 +86,14 @@ public static partial class DiceRules
var diceCount = string.IsNullOrEmpty(countValue) ? 1 : int.Parse(countValue);
var sides = int.Parse(match.Groups["sides"].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)
return ServiceResult<DiceExpression>.Failure(validation.Error!.Code, validation.Error.Message);
var isOpenEnded = match.Groups["openEnded"].Success;
if (isOpenEnded && (diceCount != 1 || sides != 100))
{
return ServiceResult<DiceExpression>.Failure(
"invalid_expression",
"Open-ended Rolemaster rolls must use d100! with an implicit or explicit dice count of 1.");
return ServiceResult<DiceExpression>.Failure("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();

View File

@@ -18,9 +18,7 @@ public static class GameAuthorization
if (campaign.GmUserId == actorUserId)
return true;
return stateStore.CharactersById.Values.Any(character =>
character.CampaignId == campaignId &&
character.OwnerUserId == actorUserId);
return stateStore.CharactersById.Values.Any(character => character.CampaignId == campaignId && character.OwnerUserId == actorUserId);
}
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)
{
return CanViewCampaign(stateStore, actorUserId, campaign.Id) &&
(entry.Visibility == RollVisibility.Public || entry.RollerUserId == actorUserId || campaign.GmUserId == actorUserId);
return CanViewCampaign(stateStore, actorUserId, campaign.Id) && (entry.Visibility == RollVisibility.Public || entry.RollerUserId == actorUserId || campaign.GmUserId == actorUserId);
}
}

View File

@@ -49,11 +49,7 @@ public sealed class GameCampaignService
if (user is null)
return ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unauthorized", "You must be logged in.");
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();
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();
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success(results);
}
@@ -67,10 +63,7 @@ public sealed class GameCampaignService
if (user is null)
return ServiceResult<IReadOnlyList<CampaignOption>>.Failure("unauthorized", "You must be logged in.");
var options = m_StateStore.CampaignsById.Values
.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase)
.Select(GameDtoMapper.ToCampaignOption)
.ToArray();
var options = m_StateStore.CampaignsById.Values.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).Select(GameDtoMapper.ToCampaignOption).ToArray();
return ServiceResult<IReadOnlyList<CampaignOption>>.Success(options);
}

View File

@@ -62,9 +62,7 @@ public sealed class GameCharacterService
var isOwner = character.OwnerUserId == user.Id;
var isAdmin = GameAuthorization.HasRole(user, UserRoles.Admin);
var isSourceGm = character.CampaignId.HasValue &&
m_StateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) &&
sourceCampaign.GmUserId == user.Id;
var isSourceGm = character.CampaignId.HasValue && m_StateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) && sourceCampaign.GmUserId == user.Id;
var isTargetGm = targetCampaign is not null && targetCampaign.GmUserId == user.Id;
if (!isOwner && !isAdmin && !isSourceGm && !isTargetGm)
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner, GM, or admin can edit this character.");
@@ -85,13 +83,9 @@ public sealed class GameCharacterService
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the GM or admin can change character owner.");
character.OwnerUserId = targetOwnerUserId;
if (character.OwnerUserId != previousOwnerUserId &&
m_StateStore.UsersById.TryGetValue(previousOwnerUserId, out var previousOwner) &&
previousOwner.ActiveCharacterId == character.Id)
{
if (character.OwnerUserId != previousOwnerUserId && m_StateStore.UsersById.TryGetValue(previousOwnerUserId, out var previousOwner) && previousOwner.ActiveCharacterId == character.Id)
previousOwner.ActiveCharacterId = null;
}
}
if (sourceCampaignId != character.CampaignId)
{
@@ -158,11 +152,7 @@ public sealed class GameCharacterService
if (user is null)
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("unauthorized", "You must be logged in.");
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();
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();
return ServiceResult<IReadOnlyList<CharacterSummary>>.Success(characters);
}

View File

@@ -33,11 +33,9 @@ public static class GameContextResolver
public static bool TryResolveCharacterCampaignLocked(GameStateStore stateStore, Character character, out Campaign campaign, out ServiceError? error)
{
campaign = default!;
if (!character.CampaignId.HasValue ||
!stateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var resolvedCampaign) ||
resolvedCampaign is null)
if (!character.CampaignId.HasValue || !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;
}

View File

@@ -24,19 +24,15 @@ public static class GameDtoMapper
{
var gm = stateStore.UsersById[campaign.GmUserId];
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)
{
var gm = stateStore.UsersById[campaign.GmUserId];
var characters = stateStore.CharactersById.Values
.Where(character => character.CampaignId == campaign.Id)
.OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase)
.Select(character => ToCharacterSummary(stateStore, character))
.ToArray();
var characters = stateStore.CharactersById.Values.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)
@@ -46,16 +42,8 @@ public static class GameDtoMapper
public static CharacterSheet ToCharacterSheet(GameStateStore stateStore, Guid characterId)
{
var skillGroups = stateStore.SkillGroupsById.Values
.Where(group => group.CharacterId == characterId)
.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();
var skillGroups = stateStore.SkillGroupsById.Values.Where(group => group.CharacterId == characterId).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);
}
@@ -77,43 +65,12 @@ public static class GameDtoMapper
public static CampaignLogEntry ToCampaignLogEntry(RollLogEntry entry, string characterName, string skillName, string rollerDisplayName, IReadOnlyList<RollDieResult> dice)
{
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);
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);
}
public static CampaignLogListEntry ToCampaignLogListEntry(
RollLogEntry entry,
string characterName,
string skillName,
string rollerLabel,
string visibilityLabel,
string visibilityStyle,
string summaryText,
string[]? eventBadges)
public static CampaignLogListEntry ToCampaignLogListEntry(RollLogEntry entry, string characterName, string skillName, string rollerLabel, string visibilityLabel, string visibilityStyle, string summaryText, string[]? eventBadges)
{
return new(
entry.Id,
characterName,
skillName,
rollerLabel,
visibilityLabel,
visibilityStyle,
entry.Result,
summaryText,
eventBadges,
entry.TimestampUtc);
return new(entry.Id, characterName, skillName, rollerLabel, visibilityLabel, visibilityStyle, entry.Result, summaryText, eventBadges, entry.TimestampUtc);
}
public static CampaignRollDetail ToCampaignRollDetail(RollLogEntry entry, RollDieResult[] dice)
@@ -124,19 +81,14 @@ public static class GameDtoMapper
public static CampaignStateSnapshot ToCampaignStateSnapshot(GameStateStore stateStore, Guid campaignId)
{
var state = stateStore.GetOrCreateCampaignStateLocked(campaignId);
var characterVersions = state.CharacterVersions
.OrderBy(version => version.Key)
.Select(version => new CharacterStateVersion(version.Key, version.Value))
.ToArray();
var characterVersions = state.CharacterVersions.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)
{
return stateStore.UsersById.TryGetValue(ownerUserId, out var user) && !string.IsNullOrWhiteSpace(user.DisplayName)
? user.DisplayName
: fallback;
return stateStore.UsersById.TryGetValue(ownerUserId, out var user) && !string.IsNullOrWhiteSpace(user.DisplayName) ? user.DisplayName : fallback;
}
private static CharacterSheetSkillGroup ToCharacterSheetSkillGroup(SkillGroup skillGroup)

View File

@@ -11,10 +11,7 @@ public sealed class GameRollService
m_StateStore = stateStore;
m_PersistenceService = persistenceService;
m_DiceRoller = diceRoller;
m_RollEngine = new(
new StandardRollEngine(diceRoller),
new D6RollEngine(diceRoller),
new RolemasterRollEngine(diceRoller));
m_RollEngine = new(new(diceRoller), new(diceRoller), new(diceRoller));
}
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);
var (user, campaign) = context.Value!;
var entries = GetVisibleCampaignLogEntriesLocked(user, campaign)
.TakeLast(CampaignLogHistoryWindowSize)
.Select(ToLogEntry)
.ToArray();
var entries = GetVisibleCampaignLogEntriesLocked(user, campaign).TakeLast(CampaignLogHistoryWindowSize).Select(ToLogEntry).ToArray();
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Success(entries);
}
@@ -112,28 +106,28 @@ public sealed class GameRollService
if (!afterRollId.HasValue)
{
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);
if (afterIndex < 0)
{
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();
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)
{
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();
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(
UserAccount user,
Campaign campaign,
Character character,
Guid skillId,
RollVisibility visibility,
(int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) roll,
string canonicalExpression)
private ServiceResult<RollResult> RecordRollLocked(UserAccount user, Campaign campaign, Character character, Guid skillId, RollVisibility visibility, (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) roll, string canonicalExpression)
{
var entry = new RollLogEntry
{
@@ -200,18 +187,12 @@ public sealed class GameRollService
private static string FormatLoggedBreakdown(Guid skillId, string canonicalExpression, string breakdown)
{
return skillId == CustomRollSkillId
? $"{canonicalExpression}{CustomRollBreakdownSeparator}{breakdown}"
: breakdown;
return skillId == CustomRollSkillId ? $"{canonicalExpression}{CustomRollBreakdownSeparator}{breakdown}" : breakdown;
}
private IEnumerable<RollLogEntry> GetVisibleCampaignLogEntriesLocked(UserAccount user, Campaign campaign)
{
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);
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);
}
private CampaignLogEntry ToLogEntry(RollLogEntry entry)
@@ -232,15 +213,7 @@ public sealed class GameRollService
var loggedExpression = ResolveLoggedExpression(entry);
var eventBadges = CampaignLogSummaryBuilder.BuildCompactLogEventBadges(campaign.Ruleset, loggedExpression, dice);
return GameDtoMapper.ToCampaignLogListEntry(
entry,
characterName,
skillName,
ResolveLogRollerLabel(user, campaign, entry),
ResolveLogVisibilityLabel(user, campaign, entry),
ResolveLogVisibilityStyle(user, campaign, entry),
CampaignLogSummaryBuilder.BuildCompactLogSummary(dice),
eventBadges);
return GameDtoMapper.ToCampaignLogListEntry(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)
@@ -323,8 +296,8 @@ public sealed class GameRollService
private const int CampaignLogHistoryWindowSize = 100;
private const int CampaignLogLivePageSize = 25;
private const string CustomRollBreakdownSeparator = " => ";
private static readonly Guid CustomRollSkillId = Guid.Empty;
private const string CustomRollLabel = "Custom roll";
private static readonly Guid CustomRollSkillId = Guid.Empty;
private static readonly JsonSerializerOptions DiceJsonOptions = RpgRollerJson.CreateSerializerOptions();
private readonly IDiceRoller m_DiceRoller;
private readonly GamePersistenceService m_PersistenceService;

View File

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

View File

@@ -4,22 +4,11 @@ namespace RpgRoller.Services;
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)
{
if (!CampaignStateById.TryGetValue(campaignId, out var state))
{
state = new GameCampaignStateTracker();
state = new();
CampaignStateById[campaignId] = state;
}
@@ -31,7 +20,7 @@ public sealed class GameStateStore
CampaignStateById.Clear();
foreach (var campaignId in CampaignsById.Keys)
CampaignStateById[campaignId] = new GameCampaignStateTracker();
CampaignStateById[campaignId] = new();
foreach (var character in CharactersById.Values.Where(character => character.CampaignId.HasValue))
AddCharacterStateLocked(character.CampaignId, character.Id);
@@ -83,6 +72,17 @@ public sealed class GameStateStore
state.TotalVersion += 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

View File

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

View File

@@ -14,12 +14,7 @@ public static class RoleSerializer
public static string[] Normalize(IEnumerable<string> roles)
{
return roles
.Where(role => !string.IsNullOrWhiteSpace(role))
.Select(role => role.Trim().ToLowerInvariant())
.Distinct(StringComparer.Ordinal)
.OrderBy(role => role, StringComparer.Ordinal)
.ToArray();
return roles.Where(role => !string.IsNullOrWhiteSpace(role)).Select(role => role.Trim().ToLowerInvariant()).Distinct(StringComparer.Ordinal).OrderBy(role => role, StringComparer.Ordinal).ToArray();
}
public static bool HasRole(string serializedRoles, string role)

View File

@@ -40,22 +40,19 @@ public sealed class RolemasterRollEngine
var initialRoll = m_DiceRoller.Roll(expression.Sides);
var followUpRolls = new List<int>();
int? initialContribution = initialRoll <= fumbleRange ? null : initialRoll;
var dice = new List<RollDieResult>
{
CreateRolemasterDie(initialRoll, 1, RollDieKinds.RolemasterOpenEndedInitial, initialContribution)
};
var dice = new List<RollDieResult> { CreateRolemasterDie(initialRoll, 1, RollDieKinds.RolemasterOpenEndedInitial, initialContribution) };
var baseTotal = initialRoll <= fumbleRange ? 0 : initialRoll;
var subtractFollowUps = false;
if (initialRoll >= 96)
{
followUpRolls.AddRange(RollHighOpenEndedChain(dice, sequenceStart: 2, subtract: false));
followUpRolls.AddRange(RollHighOpenEndedChain(dice, 2, false));
baseTotal += followUpRolls.Sum();
}
else if (initialRoll <= fumbleRange)
{
subtractFollowUps = true;
followUpRolls.AddRange(RollHighOpenEndedChain(dice, sequenceStart: 2, subtract: true));
followUpRolls.AddRange(RollHighOpenEndedChain(dice, 2, true));
baseTotal -= followUpRolls.Sum();
}
@@ -73,11 +70,7 @@ public sealed class RolemasterRollEngine
{
var roll = m_DiceRoller.Roll(100);
followUpRolls.Add(roll);
dice.Add(CreateRolemasterDie(
roll,
sequence,
subtract ? RollDieKinds.RolemasterOpenEndedLowSubtract : RollDieKinds.RolemasterOpenEndedHigh,
subtract ? -roll : roll));
dice.Add(CreateRolemasterDie(roll, sequence, subtract ? RollDieKinds.RolemasterOpenEndedLowSubtract : RollDieKinds.RolemasterOpenEndedHigh, subtract ? -roll : roll));
sequence += 1;
if (roll < 96)

View File

@@ -4,12 +4,7 @@ namespace RpgRoller.Services;
public static class SkillDefinitionValidator
{
public static ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)> Validate(
RulesetKind ruleset,
string diceRollDefinition,
int wildDice,
bool allowFumble,
int? fumbleRange)
public static ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)> Validate(RulesetKind ruleset, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange)
{
var expressionValidation = DiceRules.ParseExpression(ruleset, diceRollDefinition);
if (!expressionValidation.Succeeded)
@@ -19,19 +14,10 @@ public static class SkillDefinitionValidator
if (!optionsValidation.Succeeded)
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Success((
expressionValidation.Value!.Canonical,
optionsValidation.Value!.WildDice,
optionsValidation.Value.AllowFumble,
optionsValidation.Value.FumbleRange));
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Success((expressionValidation.Value!.Canonical, optionsValidation.Value!.WildDice, optionsValidation.Value.AllowFumble, optionsValidation.Value.FumbleRange));
}
private static ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)> ValidateOptions(
RulesetKind ruleset,
DiceExpression expression,
int wildDice,
bool allowFumble,
int? fumbleRange)
private static ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)> ValidateOptions(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange)
{
if (wildDice < 0 || wildDice > 50)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_wild_dice", "Wild dice must be between 0 and 50.");

View File

@@ -20,11 +20,36 @@ function Invoke-Step {
}
}
function Remove-TestCoverageArtifacts {
param(
[Parameter(Mandatory = $true)][string]$ResultsRoot
)
if (-not (Test-Path $ResultsRoot)) {
return
}
Get-ChildItem -Path $ResultsRoot -Recurse -File -Filter "coverage.cobertura.xml" -ErrorAction SilentlyContinue |
Remove-Item -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $ResultsRoot -Recurse -Directory -ErrorAction SilentlyContinue |
Sort-Object FullName -Descending |
ForEach-Object {
if (-not (Get-ChildItem -Path $_.FullName -Force -ErrorAction SilentlyContinue | Select-Object -First 1)) {
Remove-Item -Path $_.FullName -Force -ErrorAction SilentlyContinue
}
}
}
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Split-Path -Parent $scriptDir
$testResultsRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("RpgRoller.TestResults." + [Guid]::NewGuid().ToString("N"))
$repoCoverageResultsRoot = Join-Path $repoRoot "RpgRoller.Tests\TestResults"
Push-Location $repoRoot
try {
Remove-TestCoverageArtifacts -ResultsRoot $repoCoverageResultsRoot
if (-not $SkipDotnetRestore) {
Invoke-Step -Name "Restore .NET solution" -Action {
dotnet restore RpgRoller.sln --verbosity minimal
@@ -47,15 +72,15 @@ try {
Invoke-Step -Name "Run tests" -Action {
if ($SkipBuild) {
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --verbosity minimal --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --verbosity minimal --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings --results-directory $testResultsRoot
}
else {
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --no-build --verbosity minimal --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --no-build --verbosity minimal --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings --results-directory $testResultsRoot
}
}
Invoke-Step -Name "Enforce coverage thresholds" -Action {
pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70
pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70 -ResultsRoot $testResultsRoot
}
if (-not $SkipPlaywright) {
@@ -67,5 +92,11 @@ try {
Write-Host "CI checks passed."
}
finally {
if (Test-Path $testResultsRoot) {
Remove-Item -Path $testResultsRoot -Recurse -Force -ErrorAction SilentlyContinue
}
Remove-TestCoverageArtifacts -ResultsRoot $repoCoverageResultsRoot
Pop-Location
}