From 43bd68e7075dbd81103333151d7ce7fbe5c383f8 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 5 May 2026 02:10:26 +0200 Subject: [PATCH] Allow GM play roster access --- README.md | 2 +- RpgRoller.Tests/Api/CampaignApiTests.cs | 174 +++++++++++++----- .../Services/ServicePersistenceTests.cs | 24 ++- .../Services/WorkspaceStateTests.cs | 21 ++- .../Pages/WorkspacePlayCoordinator.cs | 7 +- RpgRoller/Components/Pages/WorkspaceState.cs | 3 + RpgRoller/Services/GameCharacterService.cs | 37 +++- 7 files changed, 204 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 1780ed7..5677925 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ Current repo note: - 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 - 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 - Custom roll submission from the play screen without creating a persisted skill - Instant skill filtering in the character panel diff --git a/RpgRoller.Tests/Api/CampaignApiTests.cs b/RpgRoller.Tests/Api/CampaignApiTests.cs index 040066b..ae46a2f 100644 --- a/RpgRoller.Tests/Api/CampaignApiTests.cs +++ b/RpgRoller.Tests/Api/CampaignApiTests.cs @@ -13,26 +13,33 @@ public sealed class CampaignApiTests(WebApplicationFactory factory) : A await RegisterAsync(gmClient, "gm", "Password123", "Game Master"); await LoginAsync(gmClient, "gm", "Password123"); - var campaign = await PostAsync(gmClient, "/api/campaigns", new("Alpha Campaign", "dnd5e")); + var campaign = + await PostAsync(gmClient, "/api/campaigns", + new("Alpha Campaign", "dnd5e")); - var gmCharacter = await PostAsync(gmClient, "/api/characters", new("Arin", campaign.Id)); + var gmCharacter = + await PostAsync(gmClient, "/api/characters", + new("Arin", campaign.Id)); Assert.Equal("Game Master", gmCharacter.OwnerDisplayName); var activateResponse = await gmClient.PostAsync($"/api/characters/{gmCharacter.Id}/activate", null); Assert.Equal(HttpStatusCode.OK, activateResponse.StatusCode); - var createdSkill = await PostAsync(gmClient, $"/api/characters/{gmCharacter.Id}/skills", new("Arcana", "2d12+2", 0, false)); + var createdSkill = await PostAsync(gmClient, + $"/api/characters/{gmCharacter.Id}/skills", new("Arcana", "2d12+2", 0, false)); Assert.Equal("2d12+2", createdSkill.DiceRollDefinition); Assert.Equal(0, createdSkill.WildDice); Assert.False(createdSkill.AllowFumble); - var updatedSkill = await PutAsync(gmClient, $"/api/skills/{createdSkill.Id}", new("Arcana Mastery", "2d12+3", 0, false)); + var updatedSkill = await PutAsync(gmClient, $"/api/skills/{createdSkill.Id}", + new("Arcana Mastery", "2d12+3", 0, false)); Assert.Equal("Arcana Mastery", updatedSkill.Name); Assert.Equal("2d12+3", updatedSkill.DiceRollDefinition); Assert.Equal(0, updatedSkill.WildDice); 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); var details = await GetAsync(gmClient, $"/api/campaigns/{campaign.Id}"); @@ -53,14 +60,49 @@ public sealed class CampaignApiTests(WebApplicationFactory factory) : A Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id); Assert.Equal("Game Master", currentCampaignCharacters[0].OwnerDisplayName); - var otherCampaign = await PostAsync(gmClient, "/api/campaigns", new("Beta Campaign", "d6")); + var otherCampaign = + await PostAsync(gmClient, "/api/campaigns", + new("Beta Campaign", "d6")); - var updatedCharacter = await PutAsync(gmClient, $"/api/characters/{gmCharacter.Id}", new("Arin Updated", otherCampaign.Id)); + var updatedCharacter = await PutAsync(gmClient, + $"/api/characters/{gmCharacter.Id}", new("Arin Updated", otherCampaign.Id)); Assert.Equal("Arin Updated", updatedCharacter.Name); 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(gmClient, "/api/campaigns", + new("Activation Campaign", "d6")); + var playerCharacter = await PostAsync(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(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] public async Task CampaignCreation_AcceptsRolemasterRuleset() { @@ -70,7 +112,9 @@ public sealed class CampaignApiTests(WebApplicationFactory factory) : A await RegisterAsync(gmClient, "gm-rm-api", "Password123", "Game Master"); await LoginAsync(gmClient, "gm-rm-api", "Password123"); - var campaign = await PostAsync(gmClient, "/api/campaigns", new("Shadow World", "rolemaster")); + var campaign = + await PostAsync(gmClient, "/api/campaigns", + new("Shadow World", "rolemaster")); Assert.Equal("rolemaster", campaign.RulesetId); } @@ -84,23 +128,32 @@ public sealed class CampaignApiTests(WebApplicationFactory factory) : A await RegisterAsync(gmClient, "gm-rm-skill", "Password123", "Game Master"); await LoginAsync(gmClient, "gm-rm-skill", "Password123"); - var campaign = await PostAsync(gmClient, "/api/campaigns", new("Shadow World", "rolemaster")); - var character = await PostAsync(gmClient, "/api/characters", new("Kalen", campaign.Id)); + var campaign = + await PostAsync(gmClient, "/api/campaigns", + new("Shadow World", "rolemaster")); + var character = + await PostAsync(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); - var group = await PostAsync(gmClient, $"/api/characters/{character.Id}/skill-groups", new("Perception", "d100!+15", 0, false, 5)); + var group = await PostAsync(gmClient, + $"/api/characters/{character.Id}/skill-groups", new("Perception", "d100!+15", 0, false, 5)); 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); - var skill = await PostAsync(gmClient, $"/api/characters/{character.Id}/skills", new("Awareness", "d100!+35", 0, false, group.Id, 3, true)); + var skill = await PostAsync(gmClient, + $"/api/characters/{character.Id}/skills", new("Awareness", "d100!+35", 0, false, group.Id, 3, true)); Assert.Equal(3, skill.FumbleRange); Assert.True(skill.RolemasterAutoRetry); - var updatedSkill = await PutAsync(gmClient, $"/api/skills/{skill.Id}", new("Awareness", "d100!+45", 0, false, group.Id, 4, true)); + var updatedSkill = await PutAsync(gmClient, $"/api/skills/{skill.Id}", + new("Awareness", "d100!+45", 0, false, group.Id, 4, true)); Assert.Equal(4, updatedSkill.FumbleRange); Assert.True(updatedSkill.RolemasterAutoRetry); @@ -128,23 +181,31 @@ public sealed class CampaignApiTests(WebApplicationFactory factory) : A await RegisterAsync(receiverClient, "receiver2", "Password123", "Receiver"); await LoginAsync(receiverClient, "receiver2", "Password123"); - var campaign = await PostAsync(gmClient, "/api/campaigns", new("Grouped Campaign", "d6")); - var character = await PostAsync(ownerClient, "/api/characters", new("Grouped Hero", campaign.Id)); + var campaign = + await PostAsync(gmClient, "/api/campaigns", + new("Grouped Campaign", "d6")); + var character = await PostAsync(ownerClient, "/api/characters", + new("Grouped Hero", campaign.Id)); - var createdGroup = await PostAsync(ownerClient, $"/api/characters/{character.Id}/skill-groups", new("Combat", "2D+1", 1, true)); - var renamedGroup = await PutAsync(gmClient, $"/api/skill-groups/{createdGroup.Id}", new("Battle", "3D+2", 2, false)); + var createdGroup = await PostAsync(ownerClient, + $"/api/characters/{character.Id}/skill-groups", new("Combat", "2D+1", 1, true)); + var renamedGroup = await PutAsync(gmClient, + $"/api/skill-groups/{createdGroup.Id}", new("Battle", "3D+2", 2, false)); Assert.Equal("Battle", renamedGroup.Name); Assert.Equal("3D+2", renamedGroup.DiceRollDefinition); Assert.Equal(2, renamedGroup.WildDice); Assert.False(renamedGroup.AllowFumble); - var groupedSkill = await PostAsync(ownerClient, $"/api/characters/{character.Id}/skills", new("Strike", "2D+1", 1, true, renamedGroup.Id)); + var groupedSkill = await PostAsync(ownerClient, + $"/api/characters/{character.Id}/skills", new("Strike", "2D+1", 1, true, renamedGroup.Id)); Assert.Equal(renamedGroup.Id, groupedSkill.SkillGroupId); - var ungroupedSkill = await PutAsync(ownerClient, $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true)); + var ungroupedSkill = await PutAsync(ownerClient, + $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true)); Assert.Null(ungroupedSkill.SkillGroupId); - var groupedAgainSkill = await PutAsync(ownerClient, $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true, renamedGroup.Id)); + var groupedAgainSkill = await PutAsync(ownerClient, + $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true, renamedGroup.Id)); Assert.Equal(renamedGroup.Id, groupedAgainSkill.SkillGroupId); var deleteSkill = await ownerClient.DeleteAsync($"/api/skills/{groupedAgainSkill.Id}"); @@ -153,7 +214,8 @@ public sealed class CampaignApiTests(WebApplicationFactory factory) : A var deleteGroup = await ownerClient.DeleteAsync($"/api/skill-groups/{renamedGroup.Id}"); Assert.Equal(HttpStatusCode.OK, deleteGroup.StatusCode); - var transferResult = await PutAsync(gmClient, $"/api/characters/{character.Id}", new("Grouped Hero", campaign.Id, "receiver2")); + var transferResult = await PutAsync(gmClient, + $"/api/characters/{character.Id}", new("Grouped Hero", campaign.Id, "receiver2")); Assert.Equal("Grouped Hero", transferResult.Name); Assert.Equal("Receiver", transferResult.OwnerDisplayName); @@ -190,12 +252,17 @@ public sealed class CampaignApiTests(WebApplicationFactory factory) : A Assert.Contains(adminEntry.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase)); Assert.Empty(playerEntry.Roles); - var promotedPlayer = await PutAsync(adminClient, $"/api/admin/users/{player.Id}/roles", new(["admin"])); + var promotedPlayer = await PutAsync(adminClient, + $"/api/admin/users/{player.Id}/roles", new(["admin"])); Assert.Contains(promotedPlayer.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase)); - var campaign = await PostAsync(gmClient, "/api/campaigns", new("Disposable Campaign", "d6")); - var character = await PostAsync(playerClient, "/api/characters", new("Disposable Hero", campaign.Id)); - var skill = await PostAsync(playerClient, $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true)); + var campaign = + await PostAsync(gmClient, "/api/campaigns", + new("Disposable Campaign", "d6")); + var character = await PostAsync(playerClient, "/api/characters", + new("Disposable Hero", campaign.Id)); + var skill = await PostAsync(playerClient, + $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true)); _ = await PostAsync(playerClient, $"/api/skills/{skill.Id}/roll", new("public")); var deleteCampaign = await adminClient.DeleteAsync($"/api/campaigns/{campaign.Id}"); @@ -267,13 +334,18 @@ public sealed class CampaignApiTests(WebApplicationFactory factory) : A await RegisterAsync(playerClient, "player-options", "Password123", "Player"); await LoginAsync(playerClient, "player-options", "Password123"); - var firstCampaign = await PostAsync(gmClient, "/api/campaigns", new("Alpha Visible", "d6")); - var secondCampaign = await PostAsync(otherGmClient, "/api/campaigns", new("Beta Available", "d6")); + var firstCampaign = + await PostAsync(gmClient, "/api/campaigns", + new("Alpha Visible", "d6")); + var secondCampaign = + await PostAsync(otherGmClient, "/api/campaigns", + new("Beta Available", "d6")); var playerVisibleCampaigns = await GetAsync>(playerClient, "/api/campaigns"); Assert.Empty(playerVisibleCampaigns); - var playerCampaignOptions = await GetAsync>(playerClient, "/api/campaigns/options"); + var playerCampaignOptions = + await GetAsync>(playerClient, "/api/campaigns/options"); Assert.Equal(2, playerCampaignOptions.Count); Assert.Contains(playerCampaignOptions, option => option.Id == firstCampaign.Id); Assert.Contains(playerCampaignOptions, option => option.Id == secondCampaign.Id); @@ -300,9 +372,13 @@ public sealed class CampaignApiTests(WebApplicationFactory factory) : A await RegisterAsync(otherClient, "other-delete", "Password123", "Other"); await LoginAsync(otherClient, "other-delete", "Password123"); - var campaign = await PostAsync(gmClient, "/api/campaigns", new("Deletion Campaign", "d6")); - var ownerCharacter = await PostAsync(ownerClient, "/api/characters", new("Owner Character", campaign.Id)); - var otherCharacter = await PostAsync(otherClient, "/api/characters", new("Other Character", campaign.Id)); + var campaign = + await PostAsync(gmClient, "/api/campaigns", + new("Deletion Campaign", "d6")); + var ownerCharacter = await PostAsync(ownerClient, "/api/characters", + new("Owner Character", campaign.Id)); + var otherCharacter = await PostAsync(otherClient, "/api/characters", + new("Other Character", campaign.Id)); var gmDeleteAttempt = await gmClient.DeleteAsync($"/api/characters/{ownerCharacter.Id}"); Assert.Equal(HttpStatusCode.BadRequest, gmDeleteAttempt.StatusCode); @@ -333,14 +409,19 @@ public sealed class CampaignApiTests(WebApplicationFactory factory) : A await RegisterAsync(playerClient, "player-log-cap", "Password123", "Player"); await LoginAsync(playerClient, "player-log-cap", "Password123"); - var campaign = await PostAsync(gmClient, "/api/campaigns", new("Log Cap", "d6")); - var character = await PostAsync(playerClient, "/api/characters", new("Roller", campaign.Id)); - var skill = await PostAsync(playerClient, $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true)); + var campaign = + await PostAsync(gmClient, "/api/campaigns", new("Log Cap", "d6")); + var character = + await PostAsync(playerClient, "/api/characters", + new("Roller", campaign.Id)); + var skill = await PostAsync(playerClient, + $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true)); var rollIds = new List(); for (var i = 0; i < 105; i++) { - var roll = await PostAsync(playerClient, $"/api/skills/{skill.Id}/roll", new("public")); + var roll = await PostAsync(playerClient, $"/api/skills/{skill.Id}/roll", + new("public")); rollIds.Add(roll.RollId); } @@ -369,14 +450,19 @@ public sealed class CampaignApiTests(WebApplicationFactory factory) : A await RegisterAsync(playerClient, "player-log-page", "Password123", "Player"); await LoginAsync(playerClient, "player-log-page", "Password123"); - var campaign = await PostAsync(gmClient, "/api/campaigns", new("Log Page", "d6")); - var character = await PostAsync(playerClient, "/api/characters", new("Roller", campaign.Id)); - var skill = await PostAsync(playerClient, $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true)); + var campaign = + await PostAsync(gmClient, "/api/campaigns", new("Log Page", "d6")); + var character = + await PostAsync(playerClient, "/api/characters", + new("Roller", campaign.Id)); + var skill = await PostAsync(playerClient, + $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true)); var rollIds = new List(); for (var i = 0; i < 5; i++) { - var roll = await PostAsync(playerClient, $"/api/skills/{skill.Id}/roll", new("public")); + var roll = await PostAsync(playerClient, $"/api/skills/{skill.Id}/roll", + new("public")); rollIds.Add(roll.RollId); } @@ -393,8 +479,10 @@ public sealed class CampaignApiTests(WebApplicationFactory factory) : A Assert.False(string.IsNullOrWhiteSpace(entry.VisibilityLabel)); }); - var latestRoll = await PostAsync(playerClient, $"/api/skills/{skill.Id}/roll", new("public")); - var incrementalPage = await GetAsync(gmClient, $"/api/campaigns/{campaign.Id}/log/page?afterRollId={initialPage.Cursor}&limit=3"); + var latestRoll = + await PostAsync(playerClient, $"/api/skills/{skill.Id}/roll", new("public")); + var incrementalPage = await GetAsync(gmClient, + $"/api/campaigns/{campaign.Id}/log/page?afterRollId={initialPage.Cursor}&limit=3"); Assert.Single(incrementalPage.Entries); Assert.Equal(latestRoll.RollId, incrementalPage.Entries[0].RollId); diff --git a/RpgRoller.Tests/Services/ServicePersistenceTests.cs b/RpgRoller.Tests/Services/ServicePersistenceTests.cs index a0c8f3c..48484a6 100644 --- a/RpgRoller.Tests/Services/ServicePersistenceTests.cs +++ b/RpgRoller.Tests/Services/ServicePersistenceTests.cs @@ -22,7 +22,8 @@ public sealed class ServicePersistenceTests var otherSession = ServiceTestSupport.GetValue(service.Login("other", "Password123")).SessionToken; 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.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(ownerSession, Guid.NewGuid(), "Renamed", campaign.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.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(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()) { 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); } - 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(string.Empty, skill.Id, "Stealth", "2D+1", 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 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 group = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Perception", "d100!+25", 0, false, 5)); - var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Read Runes", "d100!+35", 0, false, group.Id, 3, true)); + var group = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Perception", + "d100!+25", 0, false, 5)); + var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Read Runes", + "d100!+35", 0, false, group.Id, 3, true)); 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); Assert.Equal(5, reloadedGroup.FumbleRange); diff --git a/RpgRoller.Tests/Services/WorkspaceStateTests.cs b/RpgRoller.Tests/Services/WorkspaceStateTests.cs index 3234c0d..b09677a 100644 --- a/RpgRoller.Tests/Services/WorkspaceStateTests.cs +++ b/RpgRoller.Tests/Services/WorkspaceStateTests.cs @@ -41,7 +41,7 @@ public sealed class WorkspaceStateTests } [Fact] - public void PlaySelections_FilterToOwnedCharactersAndPreferSelectedThenActive() + public void PlaySelections_ForNonGm_FilterToOwnedCharactersAndPreferSelectedThenActive() { var userId = Guid.NewGuid(); 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); } + [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] public void CampaignAndConnectionFlags_ReflectCurrentState() { diff --git a/RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs b/RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs index 54b85cd..ab2c3bb 100644 --- a/RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs +++ b/RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs @@ -328,7 +328,7 @@ public sealed class WorkspacePlayCoordinator( return; 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) return; @@ -410,9 +410,10 @@ public sealed class WorkspacePlayCoordinator( 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) diff --git a/RpgRoller/Components/Pages/WorkspaceState.cs b/RpgRoller/Components/Pages/WorkspaceState.cs index 50d9db4..4340bda 100644 --- a/RpgRoller/Components/Pages/WorkspaceState.cs +++ b/RpgRoller/Components/Pages/WorkspaceState.cs @@ -109,6 +109,9 @@ public sealed class WorkspaceState return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, []); + if (IsCurrentUserGm) + return SelectedCampaign; + var ownedCharacters = SelectedCampaign.Characters.Where(character => character.OwnerUserId == User.Id) .ToArray(); diff --git a/RpgRoller/Services/GameCharacterService.cs b/RpgRoller/Services/GameCharacterService.cs index 6653d4b..8387369 100644 --- a/RpgRoller/Services/GameCharacterService.cs +++ b/RpgRoller/Services/GameCharacterService.cs @@ -36,7 +36,8 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste } } - public ServiceResult UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null) + public ServiceResult UpdateCharacter(string sessionToken, Guid characterId, string name, + Guid? campaignId, string? ownerUsername = null) { if (string.IsNullOrWhiteSpace(name)) return ServiceResult.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 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; if (!isOwner && !isAdmin && !isSourceGm && !isTargetGm) - return ServiceResult.Failure("forbidden", "Only the owner, GM, or admin can edit this character."); + return ServiceResult.Failure("forbidden", + "Only the owner, GM, or admin can edit this character."); var sourceCampaignId = character.CampaignId; var previousOwnerUserId = character.OwnerUserId; @@ -74,10 +78,13 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste return ServiceResult.Failure("owner_not_found", "Owner username was not found."); if (targetOwnerUserId != character.OwnerUserId && !isAdmin && !isSourceGm && !isTargetGm) - return ServiceResult.Failure("forbidden", "Only the GM or admin can change character owner."); + return ServiceResult.Failure("forbidden", + "Only the GM or admin can change character owner."); 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; } @@ -130,7 +137,15 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste return ServiceResult.Failure("character_not_found", "Character was not found."); if (character.OwnerUserId != user.Id) - return ServiceResult.Failure("forbidden", "You can activate only your own character."); + { + if (!GameContextResolver.TryResolveCharacterCampaignLocked(stateStore, character, out var campaign, + out var campaignError)) + return ServiceResult.Failure(campaignError!.Code, campaignError.Message); + + if (campaign!.GmUserId != user.Id) + return ServiceResult.Failure("forbidden", + "You can activate only your own character unless you GM its campaign."); + } user.ActiveCharacterId = character.Id; persistenceService.PersistStateLocked(); @@ -146,7 +161,9 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste if (user is null) return ServiceResult>.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>.Success(characters); } @@ -160,11 +177,13 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste var campaignId = character.CampaignId; 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) 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) stateStore.SkillsById.Remove(skillId);