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

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