diff --git a/README.md b/README.md index e50cc35..4bd8409 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ dotnet dotnet-ef migrations add --project RpgRoller/RpgRoller.cs - Runtime frontend is Blazor Server with interactive components. - Browser interop is in `RpgRoller/wwwroot/js/rpgroller-api.js`. - Workspace reads are resolved server-side through scoped query services; browser interop remains for browser-only concerns such as session storage, SSE wiring, and DOM helpers. +- Live workspace refreshes now compare separate roster, per-character sheet, and log versions so unrelated state changes do not force a full roster + sheet + log reload. - Workspace campaign data is loaded in bounded slices: visible campaign summaries, a selected campaign roster, a selected character sheet, and the 100 most recent visible log entries. - OpenAPI contract source remains at `openapi/RpgRoller.json`. diff --git a/RpgRoller.Tests/Services/ServiceCampaignTests.cs b/RpgRoller.Tests/Services/ServiceCampaignTests.cs index 476bd98..88d84d9 100644 --- a/RpgRoller.Tests/Services/ServiceCampaignTests.cs +++ b/RpgRoller.Tests/Services/ServiceCampaignTests.cs @@ -127,4 +127,38 @@ public sealed class ServiceCampaignTests Assert.Contains(ownerSheet.Skills, skill => skill.Id == ownerSkill.Id); Assert.Single(otherSheet.Skills); } + + [Fact] + public void CampaignStateSnapshot_TracksRosterCharacterAndLogSlicesIndependently() + { + using var harness = ServiceTestSupport.CreateHarness(4, 5, 6, 3); + var service = harness.Service; + + service.Register("gm-state", "Password123", "GM"); + service.Register("owner-state", "Password123", "Owner"); + + var gmSession = ServiceTestSupport.GetValue(service.Login("gm-state", "Password123")).SessionToken; + var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-state", "Password123")).SessionToken; + + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "State Campaign", "d6")); + var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "State Hero", campaign.Id)); + var afterCharacterCreate = ServiceTestSupport.GetValue(service.GetCampaignStateSnapshot(ownerSession, campaign.Id)); + + var initialCharacterVersion = Assert.Single(afterCharacterCreate.CharacterVersions, version => version.CharacterId == character.Id).Version; + Assert.True(afterCharacterCreate.RosterVersion > 1); + + var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Stealth", "2D+1", 1, true)); + var afterSkillCreate = ServiceTestSupport.GetValue(service.GetCampaignStateSnapshot(ownerSession, campaign.Id)); + var updatedCharacterVersion = Assert.Single(afterSkillCreate.CharacterVersions, version => version.CharacterId == character.Id).Version; + + Assert.Equal(afterCharacterCreate.RosterVersion, afterSkillCreate.RosterVersion); + Assert.True(updatedCharacterVersion > initialCharacterVersion); + + _ = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public")); + var afterRoll = ServiceTestSupport.GetValue(service.GetCampaignStateSnapshot(ownerSession, campaign.Id)); + + Assert.Equal(afterSkillCreate.RosterVersion, afterRoll.RosterVersion); + Assert.Equal(updatedCharacterVersion, Assert.Single(afterRoll.CharacterVersions, version => version.CharacterId == character.Id).Version); + Assert.True(afterRoll.LogVersion > afterSkillCreate.LogVersion); + } } diff --git a/RpgRoller.Tests/Services/ServiceSkillRollTests.cs b/RpgRoller.Tests/Services/ServiceSkillRollTests.cs index 471db2c..f04ae49 100644 --- a/RpgRoller.Tests/Services/ServiceSkillRollTests.cs +++ b/RpgRoller.Tests/Services/ServiceSkillRollTests.cs @@ -68,9 +68,10 @@ public sealed class ServiceSkillRollTests Assert.All(ServiceTestSupport.GetValue(ownerLog), entry => Assert.NotEmpty(entry.Dice)); Assert.All(ServiceTestSupport.GetValue(gmLog), entry => Assert.NotEmpty(entry.Dice)); - var version = service.GetCampaignVersion(ownerSession, campaign.Id); - var missingVersion = service.GetCampaignVersion(ownerSession, Guid.NewGuid()); - Assert.True(version.Succeeded); - Assert.False(missingVersion.Succeeded); + var state = service.GetCampaignStateSnapshot(ownerSession, campaign.Id); + var missingState = service.GetCampaignStateSnapshot(ownerSession, Guid.NewGuid()); + Assert.True(state.Succeeded); + Assert.False(missingState.Succeeded); + Assert.True(ServiceTestSupport.GetValue(state).LogVersion > 1); } -} \ No newline at end of file +} diff --git a/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs b/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs index ddae933..45adfe8 100644 --- a/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs +++ b/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs @@ -96,6 +96,6 @@ public sealed class WorkspaceQueryServiceTests public ServiceResult GetCharacterSheet(string sessionToken, Guid characterId) => throw new NotSupportedException(); public ServiceResult RollSkill(string sessionToken, Guid skillId, string visibility) => throw new NotSupportedException(); public ServiceResult> GetCampaignLog(string sessionToken, Guid campaignId) => throw new NotSupportedException(); - public ServiceResult GetCampaignVersion(string sessionToken, Guid campaignId) => throw new NotSupportedException(); + public ServiceResult GetCampaignStateSnapshot(string sessionToken, Guid campaignId) => throw new NotSupportedException(); } } diff --git a/RpgRoller/Api/StateEventEndpoints.cs b/RpgRoller/Api/StateEventEndpoints.cs index c73360c..b682f53 100644 --- a/RpgRoller/Api/StateEventEndpoints.cs +++ b/RpgRoller/Api/StateEventEndpoints.cs @@ -10,18 +10,18 @@ internal static class StateEventEndpoints group.MapGet("/events/state", async Task (Guid campaignId, HttpContext context, IGameService game) => { var sessionToken = context.GetRequiredSessionToken(); - var versionResult = game.GetCampaignVersion(sessionToken, campaignId); - if (!versionResult.Succeeded) + var stateResult = game.GetCampaignStateSnapshot(sessionToken, campaignId); + if (!stateResult.Succeeded) { - return versionResult.Error!.Code == "unauthorized" ? TypedResults.Unauthorized() : TypedResults.BadRequest(new ApiError(versionResult.Error.Message)); + return stateResult.Error!.Code == "unauthorized" ? TypedResults.Unauthorized() : TypedResults.BadRequest(new ApiError(stateResult.Error.Message)); } context.Response.Headers.CacheControl = "no-cache"; context.Response.Headers.Connection = "keep-alive"; context.Response.ContentType = "text/event-stream"; - var lastVersion = versionResult.Value; - await context.Response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{campaignId}\",\"version\":{lastVersion}}}\n\n"); + var lastState = stateResult.Value!; + await WriteStateEventAsync(context.Response, lastState); await context.Response.Body.FlushAsync(); try @@ -30,14 +30,15 @@ internal static class StateEventEndpoints { await Task.Delay(TimeSpan.FromSeconds(1), context.RequestAborted); - var currentVersionResult = game.GetCampaignVersion(sessionToken, campaignId); - if (!currentVersionResult.Succeeded) + var currentStateResult = game.GetCampaignStateSnapshot(sessionToken, campaignId); + if (!currentStateResult.Succeeded) break; - if (currentVersionResult.Value != lastVersion) + var currentState = currentStateResult.Value!; + if (currentState.TotalVersion != lastState.TotalVersion) { - lastVersion = currentVersionResult.Value; - await context.Response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{campaignId}\",\"version\":{lastVersion}}}\n\n"); + lastState = currentState; + await WriteStateEventAsync(context.Response, currentState); } else await context.Response.WriteAsync(": heartbeat\n\n"); @@ -54,4 +55,14 @@ internal static class StateEventEndpoints return group; } -} \ No newline at end of file + + private static Task WriteStateEventAsync(HttpResponse response, CampaignStateSnapshot snapshot) + { + 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"); + } +} diff --git a/RpgRoller/Components/Pages/Workspace.razor.cs b/RpgRoller/Components/Pages/Workspace.razor.cs index d1258c4..38698c9 100644 --- a/RpgRoller/Components/Pages/Workspace.razor.cs +++ b/RpgRoller/Components/Pages/Workspace.razor.cs @@ -153,6 +153,35 @@ public partial class Workspace : IAsyncDisposable CharacterCampaignOptions = campaignOptions.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList(); } + private async Task RefreshCampaignRosterAsync() + { + if (!SelectedCampaignId.HasValue) + { + SelectedCampaign = null; + SelectedCharacterId = null; + return; + } + + SelectedCampaign = await WorkspaceQuery.GetCampaignAsync(SelectedCampaignId.Value); + SyncSelectedCharacter(); + + if (IsPlayScreen && PlaySelectedCharacterId.HasValue && SelectedCharacterId != PlaySelectedCharacterId) + SelectedCharacterId = PlaySelectedCharacterId; + + await EnsureSelectedCharacterActiveAsync(); + } + + private async Task RefreshCampaignLogAsync() + { + if (!SelectedCampaignId.HasValue || !IsPlayScreen) + { + CampaignLog = []; + return; + } + + CampaignLog = (await WorkspaceQuery.GetCampaignLogAsync(SelectedCampaignId.Value)).ToList(); + } + private async Task RefreshCampaignScopeAsync() { if (!SelectedCampaignId.HasValue) @@ -163,26 +192,17 @@ public partial class Workspace : IAsyncDisposable CampaignLog = []; SelectedCharacterId = null; ConnectionState = "offline"; + CurrentCampaignState = null; return; } IsCampaignDataLoading = true; try { - var campaignId = SelectedCampaignId.Value; - SelectedCampaign = await WorkspaceQuery.GetCampaignAsync(campaignId); - SyncSelectedCharacter(); - - if (IsPlayScreen && PlaySelectedCharacterId.HasValue && SelectedCharacterId != PlaySelectedCharacterId) - SelectedCharacterId = PlaySelectedCharacterId; - + await RefreshCampaignRosterAsync(); await RefreshSelectedCharacterSheetAsync(); - - CampaignLog = IsPlayScreen - ? (await WorkspaceQuery.GetCampaignLogAsync(campaignId)).ToList() - : []; - - await EnsureSelectedCharacterActiveAsync(); + await RefreshCampaignLogAsync(); + ResetCampaignStateTracking(); } catch (ApiRequestException ex) when (ex.StatusCode == 401) { @@ -582,37 +602,43 @@ public partial class Workspace : IAsyncDisposable private async Task OnSkillCreatedAsync(Guid _) { - await RefreshCampaignScopeAsync(); + await RefreshSelectedCharacterSheetAsync(); + ResetCampaignStateTracking(); SetStatus("Skill created.", false); } private async Task OnSkillUpdatedAsync(Guid _) { - await RefreshCampaignScopeAsync(); + await RefreshSelectedCharacterSheetAsync(); + ResetCampaignStateTracking(); SetStatus("Skill updated.", false); } private async Task OnSkillGroupCreatedAsync(Guid _) { - await RefreshCampaignScopeAsync(); + await RefreshSelectedCharacterSheetAsync(); + ResetCampaignStateTracking(); SetStatus("Skill group created.", false); } private async Task OnSkillGroupUpdatedAsync(Guid _) { - await RefreshCampaignScopeAsync(); + await RefreshSelectedCharacterSheetAsync(); + ResetCampaignStateTracking(); SetStatus("Skill group updated.", false); } private async Task OnSkillDeletedAsync(Guid _) { - await RefreshCampaignScopeAsync(); + await RefreshSelectedCharacterSheetAsync(); + ResetCampaignStateTracking(); SetStatus("Skill deleted.", false); } private async Task OnSkillGroupDeletedAsync(Guid _) { - await RefreshCampaignScopeAsync(); + await RefreshSelectedCharacterSheetAsync(); + ResetCampaignStateTracking(); SetStatus("Skill group deleted.", false); } @@ -635,7 +661,8 @@ public partial class Workspace : IAsyncDisposable { LastRoll = await ApiClient.RequestAsync("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(RollVisibility)); - await RefreshCampaignScopeAsync(); + await RefreshCampaignLogAsync(); + ResetCampaignStateTracking(); SetStatus("Roll recorded.", false); Announce("Roll result updated."); } @@ -698,15 +725,44 @@ public partial class Workspace : IAsyncDisposable } [JSInvokable] - public async Task OnStateEventReceived(long _) + public async Task OnStateEventReceived(CampaignStateSnapshot state) { if (StateRefreshInProgress) return; + if (!SelectedCampaignId.HasValue || state.CampaignId != SelectedCampaignId.Value) + return; + StateRefreshInProgress = true; try { - await RefreshCampaignScopeAsync(); + if (CurrentCampaignState is null) + { + CurrentCampaignState = state; + return; + } + + var previousState = CurrentCampaignState; + var previousSelectedCharacterId = SelectedCharacterId; + var previousSelectedCharacterVersion = GetCharacterVersion(previousState, previousSelectedCharacterId); + var rosterChanged = state.RosterVersion != previousState.RosterVersion; + var logChanged = IsPlayScreen && state.LogVersion != previousState.LogVersion; + + if (rosterChanged) + await RefreshCampaignRosterAsync(); + + var selectedCharacterChanged = previousSelectedCharacterId != SelectedCharacterId; + var selectedCharacterVersionChanged = IsPlayScreen && + !selectedCharacterChanged && + GetCharacterVersion(state, SelectedCharacterId) != previousSelectedCharacterVersion; + + if (IsPlayScreen && (selectedCharacterChanged || selectedCharacterVersionChanged)) + await RefreshSelectedCharacterSheetAsync(); + + if (logChanged) + await RefreshCampaignLogAsync(); + + CurrentCampaignState = state; } finally { @@ -935,6 +991,21 @@ public partial class Workspace : IAsyncDisposable IsScreenMenuOpen = !IsScreenMenuOpen; } + private void ResetCampaignStateTracking() + { + CurrentCampaignState = null; + } + + private static long GetCharacterVersion(CampaignStateSnapshot snapshot, Guid? characterId) + { + if (!characterId.HasValue) + return 0; + + return snapshot.CharacterVersions + .FirstOrDefault(version => version.CharacterId == characterId.Value) + ?.Version ?? 0; + } + [Inject] private IJSRuntime JS { get; set; } = null!; @@ -987,6 +1058,7 @@ public partial class Workspace : IAsyncDisposable private bool StateRefreshInProgress { get; set; } private bool HasInteractiveRenderStarted { get; set; } private DotNetObjectReference? DotNetRef { get; set; } + private CampaignStateSnapshot? CurrentCampaignState { get; set; } [Parameter] public EventCallback LoggedOut { get; set; } diff --git a/RpgRoller/Contracts/ApiContracts.cs b/RpgRoller/Contracts/ApiContracts.cs index d1426a7..bf43af0 100644 --- a/RpgRoller/Contracts/ApiContracts.cs +++ b/RpgRoller/Contracts/ApiContracts.cs @@ -53,3 +53,7 @@ public sealed record RollResult(Guid RollId, Guid CampaignId, Guid CharacterId, public sealed record CharacterSheet(Guid CharacterId, IReadOnlyList SkillGroups, IReadOnlyList Skills); public sealed record CampaignLogEntry(Guid RollId, Guid CampaignId, Guid CharacterId, string CharacterName, Guid SkillId, string SkillName, Guid RollerUserId, string RollerDisplayName, string Visibility, int Result, string Breakdown, IReadOnlyList Dice, DateTimeOffset TimestampUtc); + +public sealed record CharacterStateVersion(Guid CharacterId, long Version); + +public sealed record CampaignStateSnapshot(Guid CampaignId, long TotalVersion, long RosterVersion, long LogVersion, IReadOnlyList CharacterVersions); diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index a1843a5..7957a85 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -366,7 +366,8 @@ public sealed class GameService : IGameService }; m_CharactersById[character.Id] = character; - TouchCampaignLocked(character.CampaignId); + AddCharacterStateLocked(character.CampaignId, character.Id); + TouchRosterLocked(character.CampaignId); PersistStateLocked(); return ServiceResult.Success(ToCharacterSummary(character)); @@ -424,9 +425,15 @@ public sealed class GameService : IGameService } } - TouchCampaignLocked(sourceCampaignId); if (sourceCampaignId != character.CampaignId) - TouchCampaignLocked(character.CampaignId); + { + RemoveCharacterStateLocked(sourceCampaignId, character.Id); + AddCharacterStateLocked(character.CampaignId, character.Id); + } + + TouchRosterLocked(sourceCampaignId); + if (sourceCampaignId != character.CampaignId) + TouchRosterLocked(character.CampaignId); PersistStateLocked(); return ServiceResult.Success(ToCharacterSummary(character)); @@ -524,7 +531,7 @@ public sealed class GameService : IGameService }; m_SkillGroupsById[group.Id] = group; - TouchCampaignLocked(campaign.Id); + TouchCharacterLocked(campaign.Id, character.Id); PersistStateLocked(); return ServiceResult.Success(ToSkillGroupSummary(group)); @@ -560,7 +567,7 @@ public sealed class GameService : IGameService group.DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression; group.WildDice = prototypeValidation.Value.WildDice; group.AllowFumble = prototypeValidation.Value.AllowFumble; - TouchCampaignLocked(campaign.Id); + TouchCharacterLocked(campaign.Id, character.Id); PersistStateLocked(); return ServiceResult.Success(ToSkillGroupSummary(group)); @@ -589,7 +596,7 @@ public sealed class GameService : IGameService skill.SkillGroupId = null; m_SkillGroupsById.Remove(group.Id); - TouchCampaignLocked(campaign.Id); + TouchCharacterLocked(campaign.Id, character.Id); PersistStateLocked(); return ServiceResult.Success(true); @@ -636,7 +643,7 @@ public sealed class GameService : IGameService }; m_SkillsById[skill.Id] = skill; - TouchCampaignLocked(campaign.Id); + TouchCharacterLocked(campaign.Id, character.Id); PersistStateLocked(); return ServiceResult.Success(ToSkillSummary(skill)); @@ -677,7 +684,7 @@ public sealed class GameService : IGameService skill.WildDice = skillValidation.Value.WildDice; skill.AllowFumble = skillValidation.Value.AllowFumble; skill.SkillGroupId = resolvedSkillGroupId.Value; - TouchCampaignLocked(campaign.Id); + TouchCharacterLocked(campaign.Id, character.Id); PersistStateLocked(); return ServiceResult.Success(ToSkillSummary(skill)); @@ -703,7 +710,7 @@ public sealed class GameService : IGameService return ServiceResult.Failure("forbidden", "Only the owner or GM can edit skills."); m_SkillsById.Remove(skill.Id); - TouchCampaignLocked(campaign.Id); + TouchCharacterLocked(campaign.Id, character.Id); PersistStateLocked(); return ServiceResult.Success(true); @@ -773,7 +780,7 @@ public sealed class GameService : IGameService }; m_RollLog.Add(entry); - TouchCampaignLocked(campaign.Id); + TouchLogLocked(campaign.Id); PersistStateLocked(); return ServiceResult.Success(ToRollResult(entry, roll.Dice)); @@ -804,15 +811,15 @@ public sealed class GameService : IGameService } } - public ServiceResult GetCampaignVersion(string sessionToken, Guid campaignId) + public ServiceResult GetCampaignStateSnapshot(string sessionToken, Guid campaignId) { lock (m_Gate) { var context = ResolveContextLocked(sessionToken, campaignId); if (!context.Succeeded) - return ServiceResult.Failure(context.Error!.Code, context.Error.Message); + return ServiceResult.Failure(context.Error!.Code, context.Error.Message); - return ServiceResult.Success(context.Value!.Campaign.Version); + return ServiceResult.Success(ToCampaignStateSnapshot(context.Value!.Campaign)); } } @@ -1052,6 +1059,17 @@ public sealed class GameService : IGameService return new(character.Id, character.Name, character.OwnerUserId, character.CampaignId, ownerDisplayName); } + private CampaignStateSnapshot ToCampaignStateSnapshot(Campaign campaign) + { + var state = GetOrCreateCampaignStateLocked(campaign.Id); + var characterVersions = state.CharacterVersions + .OrderBy(version => version.Key) + .Select(version => new CharacterStateVersion(version.Key, version.Value)) + .ToArray(); + + return new CampaignStateSnapshot(campaign.Id, state.TotalVersion, state.RosterVersion, state.LogVersion, characterVersions); + } + private static SkillGroupSummary ToSkillGroupSummary(SkillGroup skillGroup) { return new(skillGroup.Id, skillGroup.CharacterId, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble); @@ -1178,6 +1196,7 @@ public sealed class GameService : IGameService m_CharactersById[characterId].CampaignId = null; m_RollLog.RemoveAll(entry => entry.CampaignId == campaignId); + m_CampaignStateById.Remove(campaignId); } private void DeleteCharacterLocked(Guid characterId) @@ -1201,7 +1220,8 @@ public sealed class GameService : IGameService foreach (var user in m_UsersById.Values.Where(account => account.ActiveCharacterId == characterId)) user.ActiveCharacterId = null; - TouchCampaignLocked(campaignId); + RemoveCharacterStateLocked(campaignId, characterId); + TouchRosterLocked(campaignId); } private static IReadOnlyList ParseRoles(string serializedRoles) @@ -1254,10 +1274,73 @@ public sealed class GameService : IGameService return m_UsersById.GetValueOrDefault(session.UserId); } - private void TouchCampaignLocked(Guid? campaignId) + private CampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId) { - if (campaignId.HasValue && m_CampaignsById.TryGetValue(campaignId.Value, out var campaign)) - campaign.Version += 1; + if (!m_CampaignStateById.TryGetValue(campaignId, out var state)) + { + state = new CampaignStateTracker(); + m_CampaignStateById[campaignId] = state; + } + + return state; + } + + private void AddCharacterStateLocked(Guid? campaignId, Guid characterId) + { + if (!campaignId.HasValue || !m_CampaignsById.ContainsKey(campaignId.Value)) + return; + + var state = GetOrCreateCampaignStateLocked(campaignId.Value); + state.CharacterVersions[characterId] = 1; + } + + private void RemoveCharacterStateLocked(Guid? campaignId, Guid characterId) + { + if (!campaignId.HasValue || !m_CampaignStateById.TryGetValue(campaignId.Value, out var state)) + return; + + state.CharacterVersions.Remove(characterId); + } + + private void TouchRosterLocked(Guid? campaignId) + { + if (!campaignId.HasValue || !m_CampaignsById.ContainsKey(campaignId.Value)) + return; + + var state = GetOrCreateCampaignStateLocked(campaignId.Value); + state.TotalVersion += 1; + state.RosterVersion += 1; + } + + private void TouchCharacterLocked(Guid? campaignId, Guid characterId) + { + if (!campaignId.HasValue || !m_CampaignsById.ContainsKey(campaignId.Value)) + return; + + var state = GetOrCreateCampaignStateLocked(campaignId.Value); + state.TotalVersion += 1; + state.CharacterVersions[characterId] = state.CharacterVersions.GetValueOrDefault(characterId, 1) + 1; + } + + private void TouchLogLocked(Guid? campaignId) + { + if (!campaignId.HasValue || !m_CampaignsById.ContainsKey(campaignId.Value)) + return; + + var state = GetOrCreateCampaignStateLocked(campaignId.Value); + state.TotalVersion += 1; + state.LogVersion += 1; + } + + private void RebuildCampaignStateLocked() + { + m_CampaignStateById.Clear(); + + foreach (var campaignId in m_CampaignsById.Keys) + m_CampaignStateById[campaignId] = new CampaignStateTracker(); + + foreach (var character in m_CharactersById.Values.Where(character => character.CampaignId.HasValue)) + AddCharacterStateLocked(character.CampaignId, character.Id); } private void LoadStateFromDatabase() @@ -1320,6 +1403,7 @@ public sealed class GameService : IGameService m_SkillsById[skill.Id] = CloneSkill(skill); m_RollLog.AddRange(logEntries.Select(CloneRollLogEntry)); + RebuildCampaignStateLocked(); } } @@ -1447,6 +1531,7 @@ public sealed class GameService : IGameService private const int CampaignLogPageSize = 100; private static readonly JsonSerializerOptions DiceJsonOptions = new(JsonSerializerDefaults.Web); private readonly Dictionary m_CampaignsById = []; + private readonly Dictionary m_CampaignStateById = []; private readonly Dictionary m_CharactersById = []; private readonly IDbContextFactory m_DbContextFactory; private readonly IDiceRoller m_DiceRoller; @@ -1458,4 +1543,12 @@ public sealed class GameService : IGameService private readonly Dictionary m_SkillsById = []; private readonly Dictionary m_UserIdsByUsername = new(StringComparer.Ordinal); private readonly Dictionary m_UsersById = []; + + private sealed class CampaignStateTracker + { + public long TotalVersion { get; set; } = 1; + public long RosterVersion { get; set; } = 1; + public long LogVersion { get; set; } = 1; + public Dictionary CharacterVersions { get; } = []; + } } diff --git a/RpgRoller/Services/IGameService.cs b/RpgRoller/Services/IGameService.cs index dd7a5da..b9b9493 100644 --- a/RpgRoller/Services/IGameService.cs +++ b/RpgRoller/Services/IGameService.cs @@ -39,5 +39,5 @@ public interface IGameService ServiceResult RollSkill(string sessionToken, Guid skillId, string visibility); ServiceResult> GetCampaignLog(string sessionToken, Guid campaignId); - ServiceResult GetCampaignVersion(string sessionToken, Guid campaignId); + ServiceResult GetCampaignStateSnapshot(string sessionToken, Guid campaignId); } diff --git a/RpgRoller/wwwroot/js/rpgroller-api.js b/RpgRoller/wwwroot/js/rpgroller-api.js index 9cc2fe5..532b376 100644 --- a/RpgRoller/wwwroot/js/rpgroller-api.js +++ b/RpgRoller/wwwroot/js/rpgroller-api.js @@ -74,10 +74,15 @@ window.rpgRollerApi = (() => { source.addEventListener("state", (event) => { try { const payload = JSON.parse(event.data); - const version = typeof payload.version === "number" ? payload.version : 0; - invokeDotNet("OnStateEventReceived", version); + invokeDotNet("OnStateEventReceived", payload); } catch { - invokeDotNet("OnStateEventReceived", 0); + invokeDotNet("OnStateEventReceived", { + campaignId: stateStream.campaignId, + totalVersion: 0, + rosterVersion: 0, + logVersion: 0, + characterVersions: [] + }); } });