Allow GM play roster access

This commit is contained in:
2026-05-05 02:10:26 +02:00
parent e574b4a37b
commit 43bd68e707
7 changed files with 204 additions and 64 deletions

View File

@@ -78,7 +78,7 @@ Current repo note:
- Campaign creation, roster reads, participant-scoped visibility, and owner and admin deletion - Campaign creation, roster reads, participant-scoped visibility, and owner and admin deletion
- Character creation, activation, owner transfer, campaign reassignment or unlinking, and owner and admin deletion - Character creation, activation, owner transfer, campaign reassignment or unlinking, and owner and admin deletion
- Skill groups with reusable defaults plus skill and skill-group create, edit, reassign, and delete flows - Skill groups with reusable defaults plus skill and skill-group create, edit, reassign, and delete flows
- Owner-scoped play workspace that lists only the current user's characters while preserving GM and admin management capabilities - Play workspace that lists the current user's characters, or the full active campaign roster when the user is that campaign's GM
- Campaign log paging, lazy-loaded roll detail, compact summaries, and live state refresh through SSE - Campaign log paging, lazy-loaded roll detail, compact summaries, and live state refresh through SSE
- Custom roll submission from the play screen without creating a persisted skill - Custom roll submission from the play screen without creating a persisted skill
- Instant skill filtering in the character panel - Instant skill filtering in the character panel

View File

@@ -13,26 +13,33 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
await RegisterAsync(gmClient, "gm", "Password123", "Game Master"); await RegisterAsync(gmClient, "gm", "Password123", "Game Master");
await LoginAsync(gmClient, "gm", "Password123"); await LoginAsync(gmClient, "gm", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Alpha Campaign", "dnd5e")); var campaign =
await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
new("Alpha Campaign", "dnd5e"));
var gmCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(gmClient, "/api/characters", new("Arin", campaign.Id)); var gmCharacter =
await PostAsync<CreateCharacterRequest, CharacterSummary>(gmClient, "/api/characters",
new("Arin", campaign.Id));
Assert.Equal("Game Master", gmCharacter.OwnerDisplayName); Assert.Equal("Game Master", gmCharacter.OwnerDisplayName);
var activateResponse = await gmClient.PostAsync($"/api/characters/{gmCharacter.Id}/activate", null); var activateResponse = await gmClient.PostAsync($"/api/characters/{gmCharacter.Id}/activate", null);
Assert.Equal(HttpStatusCode.OK, activateResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, activateResponse.StatusCode);
var createdSkill = await PostAsync<CreateSkillRequest, SkillSummary>(gmClient, $"/api/characters/{gmCharacter.Id}/skills", new("Arcana", "2d12+2", 0, false)); var createdSkill = await PostAsync<CreateSkillRequest, SkillSummary>(gmClient,
$"/api/characters/{gmCharacter.Id}/skills", new("Arcana", "2d12+2", 0, false));
Assert.Equal("2d12+2", createdSkill.DiceRollDefinition); Assert.Equal("2d12+2", createdSkill.DiceRollDefinition);
Assert.Equal(0, createdSkill.WildDice); Assert.Equal(0, createdSkill.WildDice);
Assert.False(createdSkill.AllowFumble); Assert.False(createdSkill.AllowFumble);
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(gmClient, $"/api/skills/{createdSkill.Id}", new("Arcana Mastery", "2d12+3", 0, false)); var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(gmClient, $"/api/skills/{createdSkill.Id}",
new("Arcana Mastery", "2d12+3", 0, false));
Assert.Equal("Arcana Mastery", updatedSkill.Name); Assert.Equal("Arcana Mastery", updatedSkill.Name);
Assert.Equal("2d12+3", updatedSkill.DiceRollDefinition); Assert.Equal("2d12+3", updatedSkill.DiceRollDefinition);
Assert.Equal(0, updatedSkill.WildDice); Assert.Equal(0, updatedSkill.WildDice);
Assert.False(updatedSkill.AllowFumble); Assert.False(updatedSkill.AllowFumble);
var invalidSkill = await gmClient.PostAsJsonAsync($"/api/characters/{gmCharacter.Id}/skills", new CreateSkillRequest("Broken", "5D+4", 0, false)); var invalidSkill = await gmClient.PostAsJsonAsync($"/api/characters/{gmCharacter.Id}/skills",
new CreateSkillRequest("Broken", "5D+4", 0, false));
Assert.Equal(HttpStatusCode.BadRequest, invalidSkill.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, invalidSkill.StatusCode);
var details = await GetAsync<CampaignRoster>(gmClient, $"/api/campaigns/{campaign.Id}"); var details = await GetAsync<CampaignRoster>(gmClient, $"/api/campaigns/{campaign.Id}");
@@ -53,14 +60,49 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id); Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id);
Assert.Equal("Game Master", currentCampaignCharacters[0].OwnerDisplayName); Assert.Equal("Game Master", currentCampaignCharacters[0].OwnerDisplayName);
var otherCampaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Beta Campaign", "d6")); var otherCampaign =
await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
new("Beta Campaign", "d6"));
var updatedCharacter = await PutAsync<UpdateCharacterRequest, CharacterSummary>(gmClient, $"/api/characters/{gmCharacter.Id}", new("Arin Updated", otherCampaign.Id)); var updatedCharacter = await PutAsync<UpdateCharacterRequest, CharacterSummary>(gmClient,
$"/api/characters/{gmCharacter.Id}", new("Arin Updated", otherCampaign.Id));
Assert.Equal("Arin Updated", updatedCharacter.Name); Assert.Equal("Arin Updated", updatedCharacter.Name);
Assert.Equal(otherCampaign.Id, updatedCharacter.CampaignId); Assert.Equal(otherCampaign.Id, updatedCharacter.CampaignId);
} }
[Fact]
public async Task GmCanActivateAnotherPlayersCharacter_AndMeReflectsCampaignContext()
{
using var factory = CreateFactory(3, 3, 3);
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
using var playerClient = factory.CreateClient(new() { AllowAutoRedirect = false });
using var outsiderClient = factory.CreateClient(new() { AllowAutoRedirect = false });
await RegisterAsync(gmClient, "gm-activate", "Password123", "GM");
await RegisterAsync(playerClient, "player-activate", "Password123", "Player");
await RegisterAsync(outsiderClient, "outsider-activate", "Password123", "Outsider");
await LoginAsync(gmClient, "gm-activate", "Password123");
await LoginAsync(playerClient, "player-activate", "Password123");
await LoginAsync(outsiderClient, "outsider-activate", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
new("Activation Campaign", "d6"));
var playerCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters",
new("Scout", campaign.Id));
var gmActivate = await gmClient.PostAsync($"/api/characters/{playerCharacter.Id}/activate", null);
Assert.Equal(HttpStatusCode.OK, gmActivate.StatusCode);
var gmMe = await GetAsync<MeResponse>(gmClient, "/api/me");
Assert.Equal(playerCharacter.Id, gmMe.ActiveCharacterId);
Assert.Equal(campaign.Id, gmMe.CurrentCampaignId);
var outsiderActivate = await outsiderClient.PostAsync($"/api/characters/{playerCharacter.Id}/activate", null);
Assert.Equal(HttpStatusCode.BadRequest, outsiderActivate.StatusCode);
}
[Fact] [Fact]
public async Task CampaignCreation_AcceptsRolemasterRuleset() public async Task CampaignCreation_AcceptsRolemasterRuleset()
{ {
@@ -70,7 +112,9 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
await RegisterAsync(gmClient, "gm-rm-api", "Password123", "Game Master"); await RegisterAsync(gmClient, "gm-rm-api", "Password123", "Game Master");
await LoginAsync(gmClient, "gm-rm-api", "Password123"); await LoginAsync(gmClient, "gm-rm-api", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Shadow World", "rolemaster")); var campaign =
await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
new("Shadow World", "rolemaster"));
Assert.Equal("rolemaster", campaign.RulesetId); Assert.Equal("rolemaster", campaign.RulesetId);
} }
@@ -84,23 +128,32 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
await RegisterAsync(gmClient, "gm-rm-skill", "Password123", "Game Master"); await RegisterAsync(gmClient, "gm-rm-skill", "Password123", "Game Master");
await LoginAsync(gmClient, "gm-rm-skill", "Password123"); await LoginAsync(gmClient, "gm-rm-skill", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Shadow World", "rolemaster")); var campaign =
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(gmClient, "/api/characters", new("Kalen", campaign.Id)); await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
new("Shadow World", "rolemaster"));
var character =
await PostAsync<CreateCharacterRequest, CharacterSummary>(gmClient, "/api/characters",
new("Kalen", campaign.Id));
var missingFumbleRange = await gmClient.PostAsJsonAsync($"/api/characters/{character.Id}/skills", new CreateSkillRequest("Bad Open Ended", "d100!+35", 0, false)); var missingFumbleRange = await gmClient.PostAsJsonAsync($"/api/characters/{character.Id}/skills",
new CreateSkillRequest("Bad Open Ended", "d100!+35", 0, false));
Assert.Equal(HttpStatusCode.BadRequest, missingFumbleRange.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, missingFumbleRange.StatusCode);
var group = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(gmClient, $"/api/characters/{character.Id}/skill-groups", new("Perception", "d100!+15", 0, false, 5)); var group = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(gmClient,
$"/api/characters/{character.Id}/skill-groups", new("Perception", "d100!+15", 0, false, 5));
Assert.Equal(5, group.FumbleRange); Assert.Equal(5, group.FumbleRange);
var invalidRetry = await gmClient.PostAsJsonAsync($"/api/characters/{character.Id}/skills", new CreateSkillRequest("Bad Retry", "d100+35", 0, false, group.Id, null, true)); var invalidRetry = await gmClient.PostAsJsonAsync($"/api/characters/{character.Id}/skills",
new CreateSkillRequest("Bad Retry", "d100+35", 0, false, group.Id, null, true));
Assert.Equal(HttpStatusCode.BadRequest, invalidRetry.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, invalidRetry.StatusCode);
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(gmClient, $"/api/characters/{character.Id}/skills", new("Awareness", "d100!+35", 0, false, group.Id, 3, true)); var skill = await PostAsync<CreateSkillRequest, SkillSummary>(gmClient,
$"/api/characters/{character.Id}/skills", new("Awareness", "d100!+35", 0, false, group.Id, 3, true));
Assert.Equal(3, skill.FumbleRange); Assert.Equal(3, skill.FumbleRange);
Assert.True(skill.RolemasterAutoRetry); Assert.True(skill.RolemasterAutoRetry);
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(gmClient, $"/api/skills/{skill.Id}", new("Awareness", "d100!+45", 0, false, group.Id, 4, true)); var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(gmClient, $"/api/skills/{skill.Id}",
new("Awareness", "d100!+45", 0, false, group.Id, 4, true));
Assert.Equal(4, updatedSkill.FumbleRange); Assert.Equal(4, updatedSkill.FumbleRange);
Assert.True(updatedSkill.RolemasterAutoRetry); Assert.True(updatedSkill.RolemasterAutoRetry);
@@ -128,23 +181,31 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
await RegisterAsync(receiverClient, "receiver2", "Password123", "Receiver"); await RegisterAsync(receiverClient, "receiver2", "Password123", "Receiver");
await LoginAsync(receiverClient, "receiver2", "Password123"); await LoginAsync(receiverClient, "receiver2", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Grouped Campaign", "d6")); var campaign =
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(ownerClient, "/api/characters", new("Grouped Hero", campaign.Id)); await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
new("Grouped Campaign", "d6"));
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(ownerClient, "/api/characters",
new("Grouped Hero", campaign.Id));
var createdGroup = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(ownerClient, $"/api/characters/{character.Id}/skill-groups", new("Combat", "2D+1", 1, true)); var createdGroup = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(ownerClient,
var renamedGroup = await PutAsync<UpdateSkillGroupRequest, SkillGroupSummary>(gmClient, $"/api/skill-groups/{createdGroup.Id}", new("Battle", "3D+2", 2, false)); $"/api/characters/{character.Id}/skill-groups", new("Combat", "2D+1", 1, true));
var renamedGroup = await PutAsync<UpdateSkillGroupRequest, SkillGroupSummary>(gmClient,
$"/api/skill-groups/{createdGroup.Id}", new("Battle", "3D+2", 2, false));
Assert.Equal("Battle", renamedGroup.Name); Assert.Equal("Battle", renamedGroup.Name);
Assert.Equal("3D+2", renamedGroup.DiceRollDefinition); Assert.Equal("3D+2", renamedGroup.DiceRollDefinition);
Assert.Equal(2, renamedGroup.WildDice); Assert.Equal(2, renamedGroup.WildDice);
Assert.False(renamedGroup.AllowFumble); Assert.False(renamedGroup.AllowFumble);
var groupedSkill = await PostAsync<CreateSkillRequest, SkillSummary>(ownerClient, $"/api/characters/{character.Id}/skills", new("Strike", "2D+1", 1, true, renamedGroup.Id)); var groupedSkill = await PostAsync<CreateSkillRequest, SkillSummary>(ownerClient,
$"/api/characters/{character.Id}/skills", new("Strike", "2D+1", 1, true, renamedGroup.Id));
Assert.Equal(renamedGroup.Id, groupedSkill.SkillGroupId); Assert.Equal(renamedGroup.Id, groupedSkill.SkillGroupId);
var ungroupedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(ownerClient, $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true)); var ungroupedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(ownerClient,
$"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true));
Assert.Null(ungroupedSkill.SkillGroupId); Assert.Null(ungroupedSkill.SkillGroupId);
var groupedAgainSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(ownerClient, $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true, renamedGroup.Id)); var groupedAgainSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(ownerClient,
$"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true, renamedGroup.Id));
Assert.Equal(renamedGroup.Id, groupedAgainSkill.SkillGroupId); Assert.Equal(renamedGroup.Id, groupedAgainSkill.SkillGroupId);
var deleteSkill = await ownerClient.DeleteAsync($"/api/skills/{groupedAgainSkill.Id}"); var deleteSkill = await ownerClient.DeleteAsync($"/api/skills/{groupedAgainSkill.Id}");
@@ -153,7 +214,8 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
var deleteGroup = await ownerClient.DeleteAsync($"/api/skill-groups/{renamedGroup.Id}"); var deleteGroup = await ownerClient.DeleteAsync($"/api/skill-groups/{renamedGroup.Id}");
Assert.Equal(HttpStatusCode.OK, deleteGroup.StatusCode); Assert.Equal(HttpStatusCode.OK, deleteGroup.StatusCode);
var transferResult = await PutAsync<UpdateCharacterRequest, CharacterSummary>(gmClient, $"/api/characters/{character.Id}", new("Grouped Hero", campaign.Id, "receiver2")); var transferResult = await PutAsync<UpdateCharacterRequest, CharacterSummary>(gmClient,
$"/api/characters/{character.Id}", new("Grouped Hero", campaign.Id, "receiver2"));
Assert.Equal("Grouped Hero", transferResult.Name); Assert.Equal("Grouped Hero", transferResult.Name);
Assert.Equal("Receiver", transferResult.OwnerDisplayName); Assert.Equal("Receiver", transferResult.OwnerDisplayName);
@@ -190,12 +252,17 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
Assert.Contains(adminEntry.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase)); Assert.Contains(adminEntry.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
Assert.Empty(playerEntry.Roles); Assert.Empty(playerEntry.Roles);
var promotedPlayer = await PutAsync<UpdateUserRolesRequest, AdminUserSummary>(adminClient, $"/api/admin/users/{player.Id}/roles", new(["admin"])); var promotedPlayer = await PutAsync<UpdateUserRolesRequest, AdminUserSummary>(adminClient,
$"/api/admin/users/{player.Id}/roles", new(["admin"]));
Assert.Contains(promotedPlayer.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase)); Assert.Contains(promotedPlayer.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Disposable Campaign", "d6")); var campaign =
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters", new("Disposable Hero", campaign.Id)); await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient, $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true)); new("Disposable Campaign", "d6"));
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters",
new("Disposable Hero", campaign.Id));
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient,
$"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true));
_ = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public")); _ = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public"));
var deleteCampaign = await adminClient.DeleteAsync($"/api/campaigns/{campaign.Id}"); var deleteCampaign = await adminClient.DeleteAsync($"/api/campaigns/{campaign.Id}");
@@ -267,13 +334,18 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
await RegisterAsync(playerClient, "player-options", "Password123", "Player"); await RegisterAsync(playerClient, "player-options", "Password123", "Player");
await LoginAsync(playerClient, "player-options", "Password123"); await LoginAsync(playerClient, "player-options", "Password123");
var firstCampaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Alpha Visible", "d6")); var firstCampaign =
var secondCampaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(otherGmClient, "/api/campaigns", new("Beta Available", "d6")); await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
new("Alpha Visible", "d6"));
var secondCampaign =
await PostAsync<CreateCampaignRequest, CampaignSummary>(otherGmClient, "/api/campaigns",
new("Beta Available", "d6"));
var playerVisibleCampaigns = await GetAsync<IReadOnlyList<CampaignSummary>>(playerClient, "/api/campaigns"); var playerVisibleCampaigns = await GetAsync<IReadOnlyList<CampaignSummary>>(playerClient, "/api/campaigns");
Assert.Empty(playerVisibleCampaigns); Assert.Empty(playerVisibleCampaigns);
var playerCampaignOptions = await GetAsync<IReadOnlyList<CampaignOption>>(playerClient, "/api/campaigns/options"); var playerCampaignOptions =
await GetAsync<IReadOnlyList<CampaignOption>>(playerClient, "/api/campaigns/options");
Assert.Equal(2, playerCampaignOptions.Count); Assert.Equal(2, playerCampaignOptions.Count);
Assert.Contains(playerCampaignOptions, option => option.Id == firstCampaign.Id); Assert.Contains(playerCampaignOptions, option => option.Id == firstCampaign.Id);
Assert.Contains(playerCampaignOptions, option => option.Id == secondCampaign.Id); Assert.Contains(playerCampaignOptions, option => option.Id == secondCampaign.Id);
@@ -300,9 +372,13 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
await RegisterAsync(otherClient, "other-delete", "Password123", "Other"); await RegisterAsync(otherClient, "other-delete", "Password123", "Other");
await LoginAsync(otherClient, "other-delete", "Password123"); await LoginAsync(otherClient, "other-delete", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Deletion Campaign", "d6")); var campaign =
var ownerCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(ownerClient, "/api/characters", new("Owner Character", campaign.Id)); await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
var otherCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(otherClient, "/api/characters", new("Other Character", campaign.Id)); new("Deletion Campaign", "d6"));
var ownerCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(ownerClient, "/api/characters",
new("Owner Character", campaign.Id));
var otherCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(otherClient, "/api/characters",
new("Other Character", campaign.Id));
var gmDeleteAttempt = await gmClient.DeleteAsync($"/api/characters/{ownerCharacter.Id}"); var gmDeleteAttempt = await gmClient.DeleteAsync($"/api/characters/{ownerCharacter.Id}");
Assert.Equal(HttpStatusCode.BadRequest, gmDeleteAttempt.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, gmDeleteAttempt.StatusCode);
@@ -333,14 +409,19 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
await RegisterAsync(playerClient, "player-log-cap", "Password123", "Player"); await RegisterAsync(playerClient, "player-log-cap", "Password123", "Player");
await LoginAsync(playerClient, "player-log-cap", "Password123"); await LoginAsync(playerClient, "player-log-cap", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Log Cap", "d6")); var campaign =
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters", new("Roller", campaign.Id)); await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Log Cap", "d6"));
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient, $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true)); var character =
await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters",
new("Roller", campaign.Id));
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient,
$"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true));
var rollIds = new List<Guid>(); var rollIds = new List<Guid>();
for (var i = 0; i < 105; i++) for (var i = 0; i < 105; i++)
{ {
var roll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public")); var roll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll",
new("public"));
rollIds.Add(roll.RollId); rollIds.Add(roll.RollId);
} }
@@ -369,14 +450,19 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
await RegisterAsync(playerClient, "player-log-page", "Password123", "Player"); await RegisterAsync(playerClient, "player-log-page", "Password123", "Player");
await LoginAsync(playerClient, "player-log-page", "Password123"); await LoginAsync(playerClient, "player-log-page", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Log Page", "d6")); var campaign =
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters", new("Roller", campaign.Id)); await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Log Page", "d6"));
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient, $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true)); var character =
await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters",
new("Roller", campaign.Id));
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient,
$"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true));
var rollIds = new List<Guid>(); var rollIds = new List<Guid>();
for (var i = 0; i < 5; i++) for (var i = 0; i < 5; i++)
{ {
var roll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public")); var roll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll",
new("public"));
rollIds.Add(roll.RollId); rollIds.Add(roll.RollId);
} }
@@ -393,8 +479,10 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
Assert.False(string.IsNullOrWhiteSpace(entry.VisibilityLabel)); Assert.False(string.IsNullOrWhiteSpace(entry.VisibilityLabel));
}); });
var latestRoll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public")); var latestRoll =
var incrementalPage = await GetAsync<CampaignLogPage>(gmClient, $"/api/campaigns/{campaign.Id}/log/page?afterRollId={initialPage.Cursor}&limit=3"); await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public"));
var incrementalPage = await GetAsync<CampaignLogPage>(gmClient,
$"/api/campaigns/{campaign.Id}/log/page?afterRollId={initialPage.Cursor}&limit=3");
Assert.Single(incrementalPage.Entries); Assert.Single(incrementalPage.Entries);
Assert.Equal(latestRoll.RollId, incrementalPage.Entries[0].RollId); Assert.Equal(latestRoll.RollId, incrementalPage.Entries[0].RollId);

View File

@@ -22,7 +22,8 @@ public sealed class ServicePersistenceTests
var otherSession = ServiceTestSupport.GetValue(service.Login("other", "Password123")).SessionToken; var otherSession = ServiceTestSupport.GetValue(service.Login("other", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Main", "d6")); var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Main", "d6"));
var ownerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Owner Character", campaign.Id)); var ownerCharacter =
ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Owner Character", campaign.Id));
Assert.False(service.GetMe(string.Empty).Succeeded); Assert.False(service.GetMe(string.Empty).Succeeded);
Assert.False(service.CreateCampaign(gmSession, "", "d6").Succeeded); Assert.False(service.CreateCampaign(gmSession, "", "d6").Succeeded);
@@ -32,12 +33,16 @@ public sealed class ServicePersistenceTests
Assert.False(service.UpdateCharacter(string.Empty, ownerCharacter.Id, "Renamed", campaign.Id).Succeeded); Assert.False(service.UpdateCharacter(string.Empty, ownerCharacter.Id, "Renamed", campaign.Id).Succeeded);
Assert.False(service.UpdateCharacter(ownerSession, Guid.NewGuid(), "Renamed", campaign.Id).Succeeded); Assert.False(service.UpdateCharacter(ownerSession, Guid.NewGuid(), "Renamed", campaign.Id).Succeeded);
Assert.False(service.ActivateCharacter(string.Empty, ownerCharacter.Id).Succeeded); Assert.False(service.ActivateCharacter(string.Empty, ownerCharacter.Id).Succeeded);
Assert.False(service.ActivateCharacter(gmSession, ownerCharacter.Id).Succeeded); Assert.True(service.ActivateCharacter(gmSession, ownerCharacter.Id).Succeeded);
Assert.False(service.GetOwnCharacters(string.Empty).Succeeded); Assert.False(service.GetOwnCharacters(string.Empty).Succeeded);
Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded); Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded);
Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), "Stealth", "2D+1", 1, true).Succeeded); Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), "Stealth", "2D+1", 1, true).Succeeded);
Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded); Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded);
var gmMe = ServiceTestSupport.GetValue(service.GetMe(gmSession));
Assert.Equal(ownerCharacter.Id, gmMe.ActiveCharacterId);
Assert.Equal(campaign.Id, gmMe.CurrentCampaignId);
using (var db = harness.CreateDbContext()) using (var db = harness.CreateDbContext())
{ {
var ownerUser = db.Users.Single(u => u.UsernameNormalized == "OWNER"); var ownerUser = db.Users.Single(u => u.UsernameNormalized == "OWNER");
@@ -75,7 +80,8 @@ public sealed class ServicePersistenceTests
Assert.NotNull(db.Users.Single(u => u.UsernameNormalized == "OWNER").ActiveCharacterId); Assert.NotNull(db.Users.Single(u => u.UsernameNormalized == "OWNER").ActiveCharacterId);
} }
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true)); var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1",
1, true));
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "", "2D+1", 1, true).Succeeded); Assert.False(service.UpdateSkill(ownerSession, skill.Id, "", "2D+1", 1, true).Succeeded);
Assert.False(service.UpdateSkill(string.Empty, skill.Id, "Stealth", "2D+1", 1, true).Succeeded); Assert.False(service.UpdateSkill(string.Empty, skill.Id, "Stealth", "2D+1", 1, true).Succeeded);
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "Stealth", "bad", 1, true).Succeeded); Assert.False(service.UpdateSkill(ownerSession, skill.Id, "Stealth", "bad", 1, true).Succeeded);
@@ -105,13 +111,17 @@ public sealed class ServicePersistenceTests
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-rm-persist", "Password123")).SessionToken; var gmSession = ServiceTestSupport.GetValue(service.Login("gm-rm-persist", "Password123")).SessionToken;
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-rm-persist", "Password123")).SessionToken; var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-rm-persist", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Persistence", "rolemaster")); var campaign =
ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Persistence", "rolemaster"));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Loremaster", campaign.Id)); var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Loremaster", campaign.Id));
var group = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Perception", "d100!+25", 0, false, 5)); var group = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Perception",
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Read Runes", "d100!+35", 0, false, group.Id, 3, true)); "d100!+25", 0, false, 5));
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Read Runes",
"d100!+35", 0, false, group.Id, 3, true));
using var reloadedHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath); using var reloadedHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath);
var reloadedSheet = ServiceTestSupport.GetValue(reloadedHarness.Service.GetCharacterSheet(ownerSession, character.Id)); var reloadedSheet =
ServiceTestSupport.GetValue(reloadedHarness.Service.GetCharacterSheet(ownerSession, character.Id));
var reloadedGroup = Assert.Single(reloadedSheet.SkillGroups, current => current.Id == group.Id); var reloadedGroup = Assert.Single(reloadedSheet.SkillGroups, current => current.Id == group.Id);
Assert.Equal(5, reloadedGroup.FumbleRange); Assert.Equal(5, reloadedGroup.FumbleRange);

View File

@@ -41,7 +41,7 @@ public sealed class WorkspaceStateTests
} }
[Fact] [Fact]
public void PlaySelections_FilterToOwnedCharactersAndPreferSelectedThenActive() public void PlaySelections_ForNonGm_FilterToOwnedCharactersAndPreferSelectedThenActive()
{ {
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var ownedCharacter = new CharacterSummary(Guid.NewGuid(), "Owned", userId, Guid.NewGuid(), "User"); var ownedCharacter = new CharacterSummary(Guid.NewGuid(), "Owned", userId, Guid.NewGuid(), "User");
@@ -70,6 +70,25 @@ public sealed class WorkspaceStateTests
Assert.Equal(ownedCharacter.Id, state.PlaySelectedCharacterId); Assert.Equal(ownedCharacter.Id, state.PlaySelectedCharacterId);
} }
[Fact]
public void PlaySelections_ForGm_ExposeEntireCampaignAndKeepNonOwnedSelection()
{
var gmId = Guid.NewGuid();
var otherCharacter = new CharacterSummary(Guid.NewGuid(), "Other", Guid.NewGuid(), Guid.NewGuid(), "Other");
var ownedCharacter = new CharacterSummary(Guid.NewGuid(), "Owned", gmId, Guid.NewGuid(), "GM");
var state = new WorkspaceState
{
User = new(gmId, "gm", "GM", []),
SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(gmId, "GM"),
[ownedCharacter, otherCharacter]),
SelectedCharacterId = otherCharacter.Id
};
Assert.Equal(2, state.PlaySelectedCampaign!.Characters.Length);
Assert.Equal(otherCharacter.Id, state.PlaySelectedCharacterId);
Assert.Equal(otherCharacter.Id, state.PlaySelectedCharacter!.Id);
}
[Fact] [Fact]
public void CampaignAndConnectionFlags_ReflectCurrentState() public void CampaignAndConnectionFlags_ReflectCurrentState()
{ {

View File

@@ -328,7 +328,7 @@ public sealed class WorkspacePlayCoordinator(
return; return;
var character = state.SelectedCampaign.Characters.FirstOrDefault(c => c.Id == state.SelectedCharacterId.Value); var character = state.SelectedCampaign.Characters.FirstOrDefault(c => c.Id == state.SelectedCharacterId.Value);
if (character is null || !CanActivateCharacter(character, state.User) || if (character is null || !CanActivateCharacter(character) ||
state.ActiveCharacterId == character.Id) state.ActiveCharacterId == character.Id)
return; return;
@@ -410,9 +410,10 @@ public sealed class WorkspacePlayCoordinator(
state.FreshCampaignLogRollId = rollId; state.FreshCampaignLogRollId = rollId;
} }
private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user) private bool CanActivateCharacter(CharacterSummary character)
{ {
return user is not null && character.OwnerUserId == user.Id; return state.User is not null &&
(character.OwnerUserId == state.User.Id || state.IsCurrentUserGm);
} }
private static CampaignRollDetail ToCampaignRollDetail(RollResult roll) private static CampaignRollDetail ToCampaignRollDetail(RollResult roll)

View File

@@ -109,6 +109,9 @@ public sealed class WorkspaceState
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm,
[]); []);
if (IsCurrentUserGm)
return SelectedCampaign;
var ownedCharacters = SelectedCampaign.Characters.Where(character => character.OwnerUserId == User.Id) var ownedCharacters = SelectedCampaign.Characters.Where(character => character.OwnerUserId == User.Id)
.ToArray(); .ToArray();

View File

@@ -36,7 +36,8 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste
} }
} }
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null) public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name,
Guid? campaignId, string? ownerUsername = null)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required."); return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
@@ -56,10 +57,13 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste
var isOwner = character.OwnerUserId == user.Id; var isOwner = character.OwnerUserId == user.Id;
var isAdmin = GameAuthorization.HasRole(user, UserRoles.Admin); var isAdmin = GameAuthorization.HasRole(user, UserRoles.Admin);
var isSourceGm = character.CampaignId.HasValue && stateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) && sourceCampaign.GmUserId == user.Id; var isSourceGm = character.CampaignId.HasValue &&
stateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) &&
sourceCampaign.GmUserId == user.Id;
var isTargetGm = targetCampaign is not null && targetCampaign.GmUserId == user.Id; var isTargetGm = targetCampaign is not null && targetCampaign.GmUserId == user.Id;
if (!isOwner && !isAdmin && !isSourceGm && !isTargetGm) if (!isOwner && !isAdmin && !isSourceGm && !isTargetGm)
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner, GM, or admin can edit this character."); return ServiceResult<CharacterSummary>.Failure("forbidden",
"Only the owner, GM, or admin can edit this character.");
var sourceCampaignId = character.CampaignId; var sourceCampaignId = character.CampaignId;
var previousOwnerUserId = character.OwnerUserId; var previousOwnerUserId = character.OwnerUserId;
@@ -74,10 +78,13 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste
return ServiceResult<CharacterSummary>.Failure("owner_not_found", "Owner username was not found."); return ServiceResult<CharacterSummary>.Failure("owner_not_found", "Owner username was not found.");
if (targetOwnerUserId != character.OwnerUserId && !isAdmin && !isSourceGm && !isTargetGm) if (targetOwnerUserId != character.OwnerUserId && !isAdmin && !isSourceGm && !isTargetGm)
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the GM or admin can change character owner."); return ServiceResult<CharacterSummary>.Failure("forbidden",
"Only the GM or admin can change character owner.");
character.OwnerUserId = targetOwnerUserId; character.OwnerUserId = targetOwnerUserId;
if (character.OwnerUserId != previousOwnerUserId && stateStore.UsersById.TryGetValue(previousOwnerUserId, out var previousOwner) && previousOwner.ActiveCharacterId == character.Id) if (character.OwnerUserId != previousOwnerUserId &&
stateStore.UsersById.TryGetValue(previousOwnerUserId, out var previousOwner) &&
previousOwner.ActiveCharacterId == character.Id)
previousOwner.ActiveCharacterId = null; previousOwner.ActiveCharacterId = null;
} }
@@ -130,7 +137,15 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste
return ServiceResult<bool>.Failure("character_not_found", "Character was not found."); return ServiceResult<bool>.Failure("character_not_found", "Character was not found.");
if (character.OwnerUserId != user.Id) if (character.OwnerUserId != user.Id)
return ServiceResult<bool>.Failure("forbidden", "You can activate only your own character."); {
if (!GameContextResolver.TryResolveCharacterCampaignLocked(stateStore, character, out var campaign,
out var campaignError))
return ServiceResult<bool>.Failure(campaignError!.Code, campaignError.Message);
if (campaign!.GmUserId != user.Id)
return ServiceResult<bool>.Failure("forbidden",
"You can activate only your own character unless you GM its campaign.");
}
user.ActiveCharacterId = character.Id; user.ActiveCharacterId = character.Id;
persistenceService.PersistStateLocked(); persistenceService.PersistStateLocked();
@@ -146,7 +161,9 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste
if (user is null) if (user is null)
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("unauthorized", "You must be logged in."); return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("unauthorized", "You must be logged in.");
var characters = stateStore.CharactersById.Values.Where(character => character.OwnerUserId == user.Id).OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase).Select(character => GameDtoMapper.ToCharacterSummary(stateStore, character)).ToArray(); var characters = stateStore.CharactersById.Values.Where(character => character.OwnerUserId == user.Id)
.OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase)
.Select(character => GameDtoMapper.ToCharacterSummary(stateStore, character)).ToArray();
return ServiceResult<IReadOnlyList<CharacterSummary>>.Success(characters); return ServiceResult<IReadOnlyList<CharacterSummary>>.Success(characters);
} }
@@ -160,11 +177,13 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste
var campaignId = character.CampaignId; var campaignId = character.CampaignId;
stateStore.CharactersById.Remove(characterId); stateStore.CharactersById.Remove(characterId);
var skillGroupIds = stateStore.SkillGroupsById.Values.Where(group => group.CharacterId == characterId).Select(group => group.Id).ToHashSet(); var skillGroupIds = stateStore.SkillGroupsById.Values.Where(group => group.CharacterId == characterId)
.Select(group => group.Id).ToHashSet();
foreach (var skillGroupId in skillGroupIds) foreach (var skillGroupId in skillGroupIds)
stateStore.SkillGroupsById.Remove(skillGroupId); stateStore.SkillGroupsById.Remove(skillGroupId);
var skillIds = stateStore.SkillsById.Values.Where(skill => skill.CharacterId == characterId).Select(skill => skill.Id).ToHashSet(); var skillIds = stateStore.SkillsById.Values.Where(skill => skill.CharacterId == characterId)
.Select(skill => skill.Id).ToHashSet();
foreach (var skillId in skillIds) foreach (var skillId in skillIds)
stateStore.SkillsById.Remove(skillId); stateStore.SkillsById.Remove(skillId);