Add targeted workspace live refresh
This commit is contained in:
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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; } = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: []
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user