Add targeted workspace live refresh

This commit is contained in:
2026-04-01 23:50:01 +02:00
parent 107b8b8552
commit 6ea91ee565
10 changed files with 281 additions and 60 deletions

View File

@@ -100,6 +100,7 @@ dotnet dotnet-ef migrations add <MigrationName> --project RpgRoller/RpgRoller.cs
- Runtime frontend is Blazor Server with interactive components. - Runtime frontend is Blazor Server with interactive components.
- Browser interop is in `RpgRoller/wwwroot/js/rpgroller-api.js`. - 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. - 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. - 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`. - OpenAPI contract source remains at `openapi/RpgRoller.json`.

View File

@@ -127,4 +127,38 @@ public sealed class ServiceCampaignTests
Assert.Contains(ownerSheet.Skills, skill => skill.Id == ownerSkill.Id); Assert.Contains(ownerSheet.Skills, skill => skill.Id == ownerSkill.Id);
Assert.Single(otherSheet.Skills); 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);
}
} }

View File

@@ -68,9 +68,10 @@ public sealed class ServiceSkillRollTests
Assert.All(ServiceTestSupport.GetValue(ownerLog), entry => Assert.NotEmpty(entry.Dice)); Assert.All(ServiceTestSupport.GetValue(ownerLog), entry => Assert.NotEmpty(entry.Dice));
Assert.All(ServiceTestSupport.GetValue(gmLog), entry => Assert.NotEmpty(entry.Dice)); Assert.All(ServiceTestSupport.GetValue(gmLog), entry => Assert.NotEmpty(entry.Dice));
var version = service.GetCampaignVersion(ownerSession, campaign.Id); var state = service.GetCampaignStateSnapshot(ownerSession, campaign.Id);
var missingVersion = service.GetCampaignVersion(ownerSession, Guid.NewGuid()); var missingState = service.GetCampaignStateSnapshot(ownerSession, Guid.NewGuid());
Assert.True(version.Succeeded); Assert.True(state.Succeeded);
Assert.False(missingVersion.Succeeded); Assert.False(missingState.Succeeded);
Assert.True(ServiceTestSupport.GetValue(state).LogVersion > 1);
} }
} }

View File

@@ -96,6 +96,6 @@ public sealed class WorkspaceQueryServiceTests
public ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId) => throw new NotSupportedException(); public ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId) => throw new NotSupportedException();
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility) => throw new NotSupportedException(); public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility) => throw new NotSupportedException();
public ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId) => throw new NotSupportedException(); public ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId) => throw new NotSupportedException();
public ServiceResult<long> GetCampaignVersion(string sessionToken, Guid campaignId) => throw new NotSupportedException(); public ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId) => throw new NotSupportedException();
} }
} }

View File

@@ -10,18 +10,18 @@ internal static class StateEventEndpoints
group.MapGet("/events/state", async Task<IResult> (Guid campaignId, HttpContext context, IGameService game) => group.MapGet("/events/state", async Task<IResult> (Guid campaignId, HttpContext context, IGameService game) =>
{ {
var sessionToken = context.GetRequiredSessionToken(); var sessionToken = context.GetRequiredSessionToken();
var versionResult = game.GetCampaignVersion(sessionToken, campaignId); var stateResult = game.GetCampaignStateSnapshot(sessionToken, campaignId);
if (!versionResult.Succeeded) 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.CacheControl = "no-cache";
context.Response.Headers.Connection = "keep-alive"; context.Response.Headers.Connection = "keep-alive";
context.Response.ContentType = "text/event-stream"; context.Response.ContentType = "text/event-stream";
var lastVersion = versionResult.Value; var lastState = stateResult.Value!;
await context.Response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{campaignId}\",\"version\":{lastVersion}}}\n\n"); await WriteStateEventAsync(context.Response, lastState);
await context.Response.Body.FlushAsync(); await context.Response.Body.FlushAsync();
try try
@@ -30,14 +30,15 @@ internal static class StateEventEndpoints
{ {
await Task.Delay(TimeSpan.FromSeconds(1), context.RequestAborted); await Task.Delay(TimeSpan.FromSeconds(1), context.RequestAborted);
var currentVersionResult = game.GetCampaignVersion(sessionToken, campaignId); var currentStateResult = game.GetCampaignStateSnapshot(sessionToken, campaignId);
if (!currentVersionResult.Succeeded) if (!currentStateResult.Succeeded)
break; break;
if (currentVersionResult.Value != lastVersion) var currentState = currentStateResult.Value!;
if (currentState.TotalVersion != lastState.TotalVersion)
{ {
lastVersion = currentVersionResult.Value; lastState = currentState;
await context.Response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{campaignId}\",\"version\":{lastVersion}}}\n\n"); await WriteStateEventAsync(context.Response, currentState);
} }
else else
await context.Response.WriteAsync(": heartbeat\n\n"); await context.Response.WriteAsync(": heartbeat\n\n");
@@ -54,4 +55,14 @@ internal static class StateEventEndpoints
return group; return group;
} }
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");
}
} }

View File

@@ -153,6 +153,35 @@ public partial class Workspace : IAsyncDisposable
CharacterCampaignOptions = campaignOptions.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList(); 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() private async Task RefreshCampaignScopeAsync()
{ {
if (!SelectedCampaignId.HasValue) if (!SelectedCampaignId.HasValue)
@@ -163,26 +192,17 @@ public partial class Workspace : IAsyncDisposable
CampaignLog = []; CampaignLog = [];
SelectedCharacterId = null; SelectedCharacterId = null;
ConnectionState = "offline"; ConnectionState = "offline";
CurrentCampaignState = null;
return; return;
} }
IsCampaignDataLoading = true; IsCampaignDataLoading = true;
try try
{ {
var campaignId = SelectedCampaignId.Value; await RefreshCampaignRosterAsync();
SelectedCampaign = await WorkspaceQuery.GetCampaignAsync(campaignId);
SyncSelectedCharacter();
if (IsPlayScreen && PlaySelectedCharacterId.HasValue && SelectedCharacterId != PlaySelectedCharacterId)
SelectedCharacterId = PlaySelectedCharacterId;
await RefreshSelectedCharacterSheetAsync(); await RefreshSelectedCharacterSheetAsync();
await RefreshCampaignLogAsync();
CampaignLog = IsPlayScreen ResetCampaignStateTracking();
? (await WorkspaceQuery.GetCampaignLogAsync(campaignId)).ToList()
: [];
await EnsureSelectedCharacterActiveAsync();
} }
catch (ApiRequestException ex) when (ex.StatusCode == 401) catch (ApiRequestException ex) when (ex.StatusCode == 401)
{ {
@@ -582,37 +602,43 @@ public partial class Workspace : IAsyncDisposable
private async Task OnSkillCreatedAsync(Guid _) private async Task OnSkillCreatedAsync(Guid _)
{ {
await RefreshCampaignScopeAsync(); await RefreshSelectedCharacterSheetAsync();
ResetCampaignStateTracking();
SetStatus("Skill created.", false); SetStatus("Skill created.", false);
} }
private async Task OnSkillUpdatedAsync(Guid _) private async Task OnSkillUpdatedAsync(Guid _)
{ {
await RefreshCampaignScopeAsync(); await RefreshSelectedCharacterSheetAsync();
ResetCampaignStateTracking();
SetStatus("Skill updated.", false); SetStatus("Skill updated.", false);
} }
private async Task OnSkillGroupCreatedAsync(Guid _) private async Task OnSkillGroupCreatedAsync(Guid _)
{ {
await RefreshCampaignScopeAsync(); await RefreshSelectedCharacterSheetAsync();
ResetCampaignStateTracking();
SetStatus("Skill group created.", false); SetStatus("Skill group created.", false);
} }
private async Task OnSkillGroupUpdatedAsync(Guid _) private async Task OnSkillGroupUpdatedAsync(Guid _)
{ {
await RefreshCampaignScopeAsync(); await RefreshSelectedCharacterSheetAsync();
ResetCampaignStateTracking();
SetStatus("Skill group updated.", false); SetStatus("Skill group updated.", false);
} }
private async Task OnSkillDeletedAsync(Guid _) private async Task OnSkillDeletedAsync(Guid _)
{ {
await RefreshCampaignScopeAsync(); await RefreshSelectedCharacterSheetAsync();
ResetCampaignStateTracking();
SetStatus("Skill deleted.", false); SetStatus("Skill deleted.", false);
} }
private async Task OnSkillGroupDeletedAsync(Guid _) private async Task OnSkillGroupDeletedAsync(Guid _)
{ {
await RefreshCampaignScopeAsync(); await RefreshSelectedCharacterSheetAsync();
ResetCampaignStateTracking();
SetStatus("Skill group deleted.", false); SetStatus("Skill group deleted.", false);
} }
@@ -635,7 +661,8 @@ public partial class Workspace : IAsyncDisposable
{ {
LastRoll = await ApiClient.RequestAsync<RollResult>("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(RollVisibility)); LastRoll = await ApiClient.RequestAsync<RollResult>("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(RollVisibility));
await RefreshCampaignScopeAsync(); await RefreshCampaignLogAsync();
ResetCampaignStateTracking();
SetStatus("Roll recorded.", false); SetStatus("Roll recorded.", false);
Announce("Roll result updated."); Announce("Roll result updated.");
} }
@@ -698,15 +725,44 @@ public partial class Workspace : IAsyncDisposable
} }
[JSInvokable] [JSInvokable]
public async Task OnStateEventReceived(long _) public async Task OnStateEventReceived(CampaignStateSnapshot state)
{ {
if (StateRefreshInProgress) if (StateRefreshInProgress)
return; return;
if (!SelectedCampaignId.HasValue || state.CampaignId != SelectedCampaignId.Value)
return;
StateRefreshInProgress = true; StateRefreshInProgress = true;
try 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 finally
{ {
@@ -935,6 +991,21 @@ public partial class Workspace : IAsyncDisposable
IsScreenMenuOpen = !IsScreenMenuOpen; 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] [Inject]
private IJSRuntime JS { get; set; } = null!; private IJSRuntime JS { get; set; } = null!;
@@ -987,6 +1058,7 @@ public partial class Workspace : IAsyncDisposable
private bool StateRefreshInProgress { get; set; } private bool StateRefreshInProgress { get; set; }
private bool HasInteractiveRenderStarted { get; set; } private bool HasInteractiveRenderStarted { get; set; }
private DotNetObjectReference<Workspace>? DotNetRef { get; set; } private DotNetObjectReference<Workspace>? DotNetRef { get; set; }
private CampaignStateSnapshot? CurrentCampaignState { get; set; }
[Parameter] [Parameter]
public EventCallback<string?> LoggedOut { get; set; } public EventCallback<string?> LoggedOut { get; set; }

View File

@@ -53,3 +53,7 @@ public sealed record RollResult(Guid RollId, Guid CampaignId, Guid CharacterId,
public sealed record CharacterSheet(Guid CharacterId, IReadOnlyList<SkillGroupSummary> SkillGroups, IReadOnlyList<SkillSummary> Skills); public sealed record CharacterSheet(Guid CharacterId, IReadOnlyList<SkillGroupSummary> SkillGroups, IReadOnlyList<SkillSummary> 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<RollDieResult> Dice, DateTimeOffset TimestampUtc); 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<RollDieResult> Dice, DateTimeOffset TimestampUtc);
public sealed record CharacterStateVersion(Guid CharacterId, long Version);
public sealed record CampaignStateSnapshot(Guid CampaignId, long TotalVersion, long RosterVersion, long LogVersion, IReadOnlyList<CharacterStateVersion> CharacterVersions);

View File

@@ -366,7 +366,8 @@ public sealed class GameService : IGameService
}; };
m_CharactersById[character.Id] = character; m_CharactersById[character.Id] = character;
TouchCampaignLocked(character.CampaignId); AddCharacterStateLocked(character.CampaignId, character.Id);
TouchRosterLocked(character.CampaignId);
PersistStateLocked(); PersistStateLocked();
return ServiceResult<CharacterSummary>.Success(ToCharacterSummary(character)); return ServiceResult<CharacterSummary>.Success(ToCharacterSummary(character));
@@ -424,9 +425,15 @@ public sealed class GameService : IGameService
} }
} }
TouchCampaignLocked(sourceCampaignId);
if (sourceCampaignId != character.CampaignId) 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(); PersistStateLocked();
return ServiceResult<CharacterSummary>.Success(ToCharacterSummary(character)); return ServiceResult<CharacterSummary>.Success(ToCharacterSummary(character));
@@ -524,7 +531,7 @@ public sealed class GameService : IGameService
}; };
m_SkillGroupsById[group.Id] = group; m_SkillGroupsById[group.Id] = group;
TouchCampaignLocked(campaign.Id); TouchCharacterLocked(campaign.Id, character.Id);
PersistStateLocked(); PersistStateLocked();
return ServiceResult<SkillGroupSummary>.Success(ToSkillGroupSummary(group)); return ServiceResult<SkillGroupSummary>.Success(ToSkillGroupSummary(group));
@@ -560,7 +567,7 @@ public sealed class GameService : IGameService
group.DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression; group.DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression;
group.WildDice = prototypeValidation.Value.WildDice; group.WildDice = prototypeValidation.Value.WildDice;
group.AllowFumble = prototypeValidation.Value.AllowFumble; group.AllowFumble = prototypeValidation.Value.AllowFumble;
TouchCampaignLocked(campaign.Id); TouchCharacterLocked(campaign.Id, character.Id);
PersistStateLocked(); PersistStateLocked();
return ServiceResult<SkillGroupSummary>.Success(ToSkillGroupSummary(group)); return ServiceResult<SkillGroupSummary>.Success(ToSkillGroupSummary(group));
@@ -589,7 +596,7 @@ public sealed class GameService : IGameService
skill.SkillGroupId = null; skill.SkillGroupId = null;
m_SkillGroupsById.Remove(group.Id); m_SkillGroupsById.Remove(group.Id);
TouchCampaignLocked(campaign.Id); TouchCharacterLocked(campaign.Id, character.Id);
PersistStateLocked(); PersistStateLocked();
return ServiceResult<bool>.Success(true); return ServiceResult<bool>.Success(true);
@@ -636,7 +643,7 @@ public sealed class GameService : IGameService
}; };
m_SkillsById[skill.Id] = skill; m_SkillsById[skill.Id] = skill;
TouchCampaignLocked(campaign.Id); TouchCharacterLocked(campaign.Id, character.Id);
PersistStateLocked(); PersistStateLocked();
return ServiceResult<SkillSummary>.Success(ToSkillSummary(skill)); return ServiceResult<SkillSummary>.Success(ToSkillSummary(skill));
@@ -677,7 +684,7 @@ public sealed class GameService : IGameService
skill.WildDice = skillValidation.Value.WildDice; skill.WildDice = skillValidation.Value.WildDice;
skill.AllowFumble = skillValidation.Value.AllowFumble; skill.AllowFumble = skillValidation.Value.AllowFumble;
skill.SkillGroupId = resolvedSkillGroupId.Value; skill.SkillGroupId = resolvedSkillGroupId.Value;
TouchCampaignLocked(campaign.Id); TouchCharacterLocked(campaign.Id, character.Id);
PersistStateLocked(); PersistStateLocked();
return ServiceResult<SkillSummary>.Success(ToSkillSummary(skill)); return ServiceResult<SkillSummary>.Success(ToSkillSummary(skill));
@@ -703,7 +710,7 @@ public sealed class GameService : IGameService
return ServiceResult<bool>.Failure("forbidden", "Only the owner or GM can edit skills."); return ServiceResult<bool>.Failure("forbidden", "Only the owner or GM can edit skills.");
m_SkillsById.Remove(skill.Id); m_SkillsById.Remove(skill.Id);
TouchCampaignLocked(campaign.Id); TouchCharacterLocked(campaign.Id, character.Id);
PersistStateLocked(); PersistStateLocked();
return ServiceResult<bool>.Success(true); return ServiceResult<bool>.Success(true);
@@ -773,7 +780,7 @@ public sealed class GameService : IGameService
}; };
m_RollLog.Add(entry); m_RollLog.Add(entry);
TouchCampaignLocked(campaign.Id); TouchLogLocked(campaign.Id);
PersistStateLocked(); PersistStateLocked();
return ServiceResult<RollResult>.Success(ToRollResult(entry, roll.Dice)); return ServiceResult<RollResult>.Success(ToRollResult(entry, roll.Dice));
@@ -804,15 +811,15 @@ public sealed class GameService : IGameService
} }
} }
public ServiceResult<long> GetCampaignVersion(string sessionToken, Guid campaignId) public ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId)
{ {
lock (m_Gate) lock (m_Gate)
{ {
var context = ResolveContextLocked(sessionToken, campaignId); var context = ResolveContextLocked(sessionToken, campaignId);
if (!context.Succeeded) if (!context.Succeeded)
return ServiceResult<long>.Failure(context.Error!.Code, context.Error.Message); return ServiceResult<CampaignStateSnapshot>.Failure(context.Error!.Code, context.Error.Message);
return ServiceResult<long>.Success(context.Value!.Campaign.Version); return ServiceResult<CampaignStateSnapshot>.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); 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) private static SkillGroupSummary ToSkillGroupSummary(SkillGroup skillGroup)
{ {
return new(skillGroup.Id, skillGroup.CharacterId, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble); 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_CharactersById[characterId].CampaignId = null;
m_RollLog.RemoveAll(entry => entry.CampaignId == campaignId); m_RollLog.RemoveAll(entry => entry.CampaignId == campaignId);
m_CampaignStateById.Remove(campaignId);
} }
private void DeleteCharacterLocked(Guid characterId) 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)) foreach (var user in m_UsersById.Values.Where(account => account.ActiveCharacterId == characterId))
user.ActiveCharacterId = null; user.ActiveCharacterId = null;
TouchCampaignLocked(campaignId); RemoveCharacterStateLocked(campaignId, characterId);
TouchRosterLocked(campaignId);
} }
private static IReadOnlyList<string> ParseRoles(string serializedRoles) private static IReadOnlyList<string> ParseRoles(string serializedRoles)
@@ -1254,10 +1274,73 @@ public sealed class GameService : IGameService
return m_UsersById.GetValueOrDefault(session.UserId); 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)) if (!m_CampaignStateById.TryGetValue(campaignId, out var state))
campaign.Version += 1; {
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() private void LoadStateFromDatabase()
@@ -1320,6 +1403,7 @@ public sealed class GameService : IGameService
m_SkillsById[skill.Id] = CloneSkill(skill); m_SkillsById[skill.Id] = CloneSkill(skill);
m_RollLog.AddRange(logEntries.Select(CloneRollLogEntry)); m_RollLog.AddRange(logEntries.Select(CloneRollLogEntry));
RebuildCampaignStateLocked();
} }
} }
@@ -1447,6 +1531,7 @@ public sealed class GameService : IGameService
private const int CampaignLogPageSize = 100; private const int CampaignLogPageSize = 100;
private static readonly JsonSerializerOptions DiceJsonOptions = new(JsonSerializerDefaults.Web); private static readonly JsonSerializerOptions DiceJsonOptions = new(JsonSerializerDefaults.Web);
private readonly Dictionary<Guid, Campaign> m_CampaignsById = []; private readonly Dictionary<Guid, Campaign> m_CampaignsById = [];
private readonly Dictionary<Guid, CampaignStateTracker> m_CampaignStateById = [];
private readonly Dictionary<Guid, Character> m_CharactersById = []; private readonly Dictionary<Guid, Character> m_CharactersById = [];
private readonly IDbContextFactory<RpgRollerDbContext> m_DbContextFactory; private readonly IDbContextFactory<RpgRollerDbContext> m_DbContextFactory;
private readonly IDiceRoller m_DiceRoller; private readonly IDiceRoller m_DiceRoller;
@@ -1458,4 +1543,12 @@ public sealed class GameService : IGameService
private readonly Dictionary<Guid, Skill> m_SkillsById = []; private readonly Dictionary<Guid, Skill> m_SkillsById = [];
private readonly Dictionary<string, Guid> m_UserIdsByUsername = new(StringComparer.Ordinal); private readonly Dictionary<string, Guid> m_UserIdsByUsername = new(StringComparer.Ordinal);
private readonly Dictionary<Guid, UserAccount> m_UsersById = []; private readonly Dictionary<Guid, UserAccount> 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<Guid, long> CharacterVersions { get; } = [];
}
} }

View File

@@ -39,5 +39,5 @@ public interface IGameService
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility); ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility);
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId); ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);
ServiceResult<long> GetCampaignVersion(string sessionToken, Guid campaignId); ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId);
} }

View File

@@ -74,10 +74,15 @@ window.rpgRollerApi = (() => {
source.addEventListener("state", (event) => { source.addEventListener("state", (event) => {
try { try {
const payload = JSON.parse(event.data); const payload = JSON.parse(event.data);
const version = typeof payload.version === "number" ? payload.version : 0; invokeDotNet("OnStateEventReceived", payload);
invokeDotNet("OnStateEventReceived", version);
} catch { } catch {
invokeDotNet("OnStateEventReceived", 0); invokeDotNet("OnStateEventReceived", {
campaignId: stateStream.campaignId,
totalVersion: 0,
rosterVersion: 0,
logVersion: 0,
characterVersions: []
});
} }
}); });