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