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.
- 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`.

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -96,6 +96,6 @@ public sealed class WorkspaceQueryServiceTests
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<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) =>
{
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;
}
}
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();
}
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<RollResult>("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<Workspace>? DotNetRef { get; set; }
private CampaignStateSnapshot? CurrentCampaignState { get; set; }
[Parameter]
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 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;
TouchCampaignLocked(character.CampaignId);
AddCharacterStateLocked(character.CampaignId, character.Id);
TouchRosterLocked(character.CampaignId);
PersistStateLocked();
return ServiceResult<CharacterSummary>.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<CharacterSummary>.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<SkillGroupSummary>.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<SkillGroupSummary>.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<bool>.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<SkillSummary>.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<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.");
m_SkillsById.Remove(skill.Id);
TouchCampaignLocked(campaign.Id);
TouchCharacterLocked(campaign.Id, character.Id);
PersistStateLocked();
return ServiceResult<bool>.Success(true);
@@ -773,7 +780,7 @@ public sealed class GameService : IGameService
};
m_RollLog.Add(entry);
TouchCampaignLocked(campaign.Id);
TouchLogLocked(campaign.Id);
PersistStateLocked();
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)
{
var context = ResolveContextLocked(sessionToken, campaignId);
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);
}
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<string> 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<Guid, Campaign> m_CampaignsById = [];
private readonly Dictionary<Guid, CampaignStateTracker> m_CampaignStateById = [];
private readonly Dictionary<Guid, Character> m_CharactersById = [];
private readonly IDbContextFactory<RpgRollerDbContext> m_DbContextFactory;
private readonly IDiceRoller m_DiceRoller;
@@ -1458,4 +1543,12 @@ public sealed class GameService : IGameService
private readonly Dictionary<Guid, Skill> m_SkillsById = [];
private readonly Dictionary<string, Guid> m_UserIdsByUsername = new(StringComparer.Ordinal);
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<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) => {
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: []
});
}
});