Extract game state persistence
This commit is contained in:
109
RpgRoller/Services/GamePersistenceService.cs
Normal file
109
RpgRoller/Services/GamePersistenceService.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RpgRoller.Data;
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Services;
|
||||
|
||||
public sealed class GamePersistenceService
|
||||
{
|
||||
public GamePersistenceService(IDbContextFactory<RpgRollerDbContext> dbContextFactory, GameStateStore stateStore)
|
||||
{
|
||||
m_DbContextFactory = dbContextFactory;
|
||||
m_StateStore = stateStore;
|
||||
}
|
||||
|
||||
public void LoadStateFromDatabase()
|
||||
{
|
||||
using var db = m_DbContextFactory.CreateDbContext();
|
||||
var users = db.Users.AsNoTracking().ToList();
|
||||
var sessions = db.Sessions.AsNoTracking().ToList();
|
||||
var campaigns = db.Campaigns.AsNoTracking().ToList();
|
||||
var characters = db.Characters.AsNoTracking().ToList();
|
||||
var skillGroups = db.SkillGroups.AsNoTracking().ToList();
|
||||
var skills = db.Skills.AsNoTracking().ToList();
|
||||
var logEntries = db.RollLogEntries.AsNoTracking().ToList().OrderBy(x => x.TimestampUtc).ThenBy(x => x.Id).ToList();
|
||||
|
||||
lock (m_StateStore.Gate)
|
||||
{
|
||||
m_StateStore.UsersById.Clear();
|
||||
m_StateStore.UserIdsByUsername.Clear();
|
||||
m_StateStore.SessionsByToken.Clear();
|
||||
m_StateStore.CampaignsById.Clear();
|
||||
m_StateStore.CharactersById.Clear();
|
||||
m_StateStore.SkillGroupsById.Clear();
|
||||
m_StateStore.SkillsById.Clear();
|
||||
m_StateStore.RollLog.Clear();
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
var normalizedUsername = string.IsNullOrWhiteSpace(user.UsernameNormalized) ? NormalizeUsername(user.Username) : user.UsernameNormalized;
|
||||
|
||||
var storedUser = new UserAccount
|
||||
{
|
||||
Id = user.Id,
|
||||
Username = user.Username,
|
||||
UsernameNormalized = normalizedUsername,
|
||||
PasswordHash = user.PasswordHash,
|
||||
DisplayName = user.DisplayName,
|
||||
Roles = string.IsNullOrWhiteSpace(user.Roles) ? string.Empty : RoleSerializer.Serialize(RoleSerializer.Parse(user.Roles)),
|
||||
ActiveCharacterId = user.ActiveCharacterId
|
||||
};
|
||||
m_StateStore.UsersById[storedUser.Id] = storedUser;
|
||||
m_StateStore.UserIdsByUsername[normalizedUsername] = storedUser.Id;
|
||||
}
|
||||
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
if (m_StateStore.UsersById.ContainsKey(session.UserId))
|
||||
m_StateStore.SessionsByToken[session.Token] = GameStateCloneFactory.CloneSession(session);
|
||||
}
|
||||
|
||||
foreach (var campaign in campaigns)
|
||||
m_StateStore.CampaignsById[campaign.Id] = GameStateCloneFactory.CloneCampaign(campaign);
|
||||
|
||||
foreach (var character in characters)
|
||||
m_StateStore.CharactersById[character.Id] = GameStateCloneFactory.CloneCharacter(character);
|
||||
|
||||
foreach (var skillGroup in skillGroups)
|
||||
m_StateStore.SkillGroupsById[skillGroup.Id] = GameStateCloneFactory.CloneSkillGroup(skillGroup);
|
||||
|
||||
foreach (var skill in skills)
|
||||
m_StateStore.SkillsById[skill.Id] = GameStateCloneFactory.CloneSkill(skill);
|
||||
|
||||
m_StateStore.RollLog.AddRange(logEntries.Select(GameStateCloneFactory.CloneRollLogEntry));
|
||||
}
|
||||
}
|
||||
|
||||
public void PersistStateLocked()
|
||||
{
|
||||
using var db = m_DbContextFactory.CreateDbContext();
|
||||
using var transaction = db.Database.BeginTransaction();
|
||||
|
||||
db.RollLogEntries.ExecuteDelete();
|
||||
db.Skills.ExecuteDelete();
|
||||
db.SkillGroups.ExecuteDelete();
|
||||
db.Characters.ExecuteDelete();
|
||||
db.Campaigns.ExecuteDelete();
|
||||
db.Sessions.ExecuteDelete();
|
||||
db.Users.ExecuteDelete();
|
||||
|
||||
db.Users.AddRange(m_StateStore.UsersById.Values.Select(GameStateCloneFactory.CloneUser));
|
||||
db.Sessions.AddRange(m_StateStore.SessionsByToken.Values.Select(GameStateCloneFactory.CloneSession));
|
||||
db.Campaigns.AddRange(m_StateStore.CampaignsById.Values.Select(GameStateCloneFactory.CloneCampaign));
|
||||
db.Characters.AddRange(m_StateStore.CharactersById.Values.Select(GameStateCloneFactory.CloneCharacter));
|
||||
db.SkillGroups.AddRange(m_StateStore.SkillGroupsById.Values.Select(GameStateCloneFactory.CloneSkillGroup));
|
||||
db.Skills.AddRange(m_StateStore.SkillsById.Values.Select(GameStateCloneFactory.CloneSkill));
|
||||
db.RollLogEntries.AddRange(m_StateStore.RollLog.Select(GameStateCloneFactory.CloneRollLogEntry));
|
||||
|
||||
db.SaveChanges();
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
private static string NormalizeUsername(string username)
|
||||
{
|
||||
return username.ToUpperInvariant();
|
||||
}
|
||||
|
||||
private readonly IDbContextFactory<RpgRollerDbContext> m_DbContextFactory;
|
||||
private readonly GameStateStore m_StateStore;
|
||||
}
|
||||
@@ -11,7 +11,18 @@ public sealed class GameService : IGameService
|
||||
{
|
||||
public GameService(IDbContextFactory<RpgRollerDbContext> dbContextFactory, IPasswordHasher<UserAccount> passwordHasher, IDiceRoller diceRoller)
|
||||
{
|
||||
m_DbContextFactory = dbContextFactory;
|
||||
m_StateStore = new();
|
||||
m_CampaignsById = m_StateStore.CampaignsById;
|
||||
m_CampaignStateById = m_StateStore.CampaignStateById;
|
||||
m_CharactersById = m_StateStore.CharactersById;
|
||||
m_Gate = m_StateStore.Gate;
|
||||
m_RollLog = m_StateStore.RollLog;
|
||||
m_SessionsByToken = m_StateStore.SessionsByToken;
|
||||
m_SkillGroupsById = m_StateStore.SkillGroupsById;
|
||||
m_SkillsById = m_StateStore.SkillsById;
|
||||
m_UserIdsByUsername = m_StateStore.UserIdsByUsername;
|
||||
m_UsersById = m_StateStore.UsersById;
|
||||
m_PersistenceService = new(dbContextFactory, m_StateStore);
|
||||
m_PasswordHasher = passwordHasher;
|
||||
m_DiceRoller = diceRoller;
|
||||
LoadStateFromDatabase();
|
||||
@@ -1652,11 +1663,11 @@ public sealed class GameService : IGameService
|
||||
return m_UsersById.GetValueOrDefault(session.UserId);
|
||||
}
|
||||
|
||||
private CampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId)
|
||||
private GameCampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId)
|
||||
{
|
||||
if (!m_CampaignStateById.TryGetValue(campaignId, out var state))
|
||||
{
|
||||
state = new CampaignStateTracker();
|
||||
state = new GameCampaignStateTracker();
|
||||
m_CampaignStateById[campaignId] = state;
|
||||
}
|
||||
|
||||
@@ -1715,7 +1726,7 @@ public sealed class GameService : IGameService
|
||||
m_CampaignStateById.Clear();
|
||||
|
||||
foreach (var campaignId in m_CampaignsById.Keys)
|
||||
m_CampaignStateById[campaignId] = new CampaignStateTracker();
|
||||
m_CampaignStateById[campaignId] = new GameCampaignStateTracker();
|
||||
|
||||
foreach (var character in m_CharactersById.Values.Where(character => character.CampaignId.HasValue))
|
||||
AddCharacterStateLocked(character.CampaignId, character.Id);
|
||||
@@ -1723,91 +1734,14 @@ public sealed class GameService : IGameService
|
||||
|
||||
private void LoadStateFromDatabase()
|
||||
{
|
||||
using var db = m_DbContextFactory.CreateDbContext();
|
||||
var users = db.Users.AsNoTracking().ToList();
|
||||
var sessions = db.Sessions.AsNoTracking().ToList();
|
||||
var campaigns = db.Campaigns.AsNoTracking().ToList();
|
||||
var characters = db.Characters.AsNoTracking().ToList();
|
||||
var skillGroups = db.SkillGroups.AsNoTracking().ToList();
|
||||
var skills = db.Skills.AsNoTracking().ToList();
|
||||
var logEntries = db.RollLogEntries.AsNoTracking().ToList().OrderBy(x => x.TimestampUtc).ThenBy(x => x.Id).ToList();
|
||||
|
||||
m_PersistenceService.LoadStateFromDatabase();
|
||||
lock (m_Gate)
|
||||
{
|
||||
m_UsersById.Clear();
|
||||
m_UserIdsByUsername.Clear();
|
||||
m_SessionsByToken.Clear();
|
||||
m_CampaignsById.Clear();
|
||||
m_CharactersById.Clear();
|
||||
m_SkillGroupsById.Clear();
|
||||
m_SkillsById.Clear();
|
||||
m_RollLog.Clear();
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
var normalizedUsername = string.IsNullOrWhiteSpace(user.UsernameNormalized) ? NormalizeUsername(user.Username) : user.UsernameNormalized;
|
||||
|
||||
var storedUser = new UserAccount
|
||||
{
|
||||
Id = user.Id,
|
||||
Username = user.Username,
|
||||
UsernameNormalized = normalizedUsername,
|
||||
PasswordHash = user.PasswordHash,
|
||||
DisplayName = user.DisplayName,
|
||||
Roles = string.IsNullOrWhiteSpace(user.Roles) ? string.Empty : RoleSerializer.Serialize(RoleSerializer.Parse(user.Roles)),
|
||||
ActiveCharacterId = user.ActiveCharacterId
|
||||
};
|
||||
m_UsersById[storedUser.Id] = storedUser;
|
||||
|
||||
m_UserIdsByUsername[normalizedUsername] = storedUser.Id;
|
||||
}
|
||||
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
if (m_UsersById.ContainsKey(session.UserId))
|
||||
m_SessionsByToken[session.Token] = CloneSession(session);
|
||||
}
|
||||
|
||||
foreach (var campaign in campaigns)
|
||||
m_CampaignsById[campaign.Id] = CloneCampaign(campaign);
|
||||
|
||||
foreach (var character in characters)
|
||||
m_CharactersById[character.Id] = CloneCharacter(character);
|
||||
|
||||
foreach (var skillGroup in skillGroups)
|
||||
m_SkillGroupsById[skillGroup.Id] = CloneSkillGroup(skillGroup);
|
||||
|
||||
foreach (var skill in skills)
|
||||
m_SkillsById[skill.Id] = CloneSkill(skill);
|
||||
|
||||
m_RollLog.AddRange(logEntries.Select(CloneRollLogEntry));
|
||||
RebuildCampaignStateLocked();
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistStateLocked()
|
||||
{
|
||||
using var db = m_DbContextFactory.CreateDbContext();
|
||||
using var transaction = db.Database.BeginTransaction();
|
||||
|
||||
db.RollLogEntries.ExecuteDelete();
|
||||
db.Skills.ExecuteDelete();
|
||||
db.SkillGroups.ExecuteDelete();
|
||||
db.Characters.ExecuteDelete();
|
||||
db.Campaigns.ExecuteDelete();
|
||||
db.Sessions.ExecuteDelete();
|
||||
db.Users.ExecuteDelete();
|
||||
|
||||
db.Users.AddRange(m_UsersById.Values.Select(CloneUser));
|
||||
db.Sessions.AddRange(m_SessionsByToken.Values.Select(CloneSession));
|
||||
db.Campaigns.AddRange(m_CampaignsById.Values.Select(CloneCampaign));
|
||||
db.Characters.AddRange(m_CharactersById.Values.Select(CloneCharacter));
|
||||
db.SkillGroups.AddRange(m_SkillGroupsById.Values.Select(CloneSkillGroup));
|
||||
db.Skills.AddRange(m_SkillsById.Values.Select(CloneSkill));
|
||||
db.RollLogEntries.AddRange(m_RollLog.Select(CloneRollLogEntry));
|
||||
|
||||
db.SaveChanges();
|
||||
transaction.Commit();
|
||||
m_PersistenceService.PersistStateLocked();
|
||||
}
|
||||
|
||||
private static string NormalizeUsername(string username)
|
||||
@@ -1823,124 +1757,24 @@ public sealed class GameService : IGameService
|
||||
return Math.Clamp(limit.Value, 1, CampaignLogLivePageSize);
|
||||
}
|
||||
|
||||
private static UserAccount CloneUser(UserAccount user)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = user.Id,
|
||||
Username = user.Username,
|
||||
UsernameNormalized = user.UsernameNormalized,
|
||||
PasswordHash = user.PasswordHash,
|
||||
DisplayName = user.DisplayName,
|
||||
Roles = user.Roles,
|
||||
ActiveCharacterId = user.ActiveCharacterId
|
||||
};
|
||||
}
|
||||
|
||||
private static UserSession CloneSession(UserSession session)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Token = session.Token,
|
||||
UserId = session.UserId,
|
||||
CreatedAtUtc = session.CreatedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static Campaign CloneCampaign(Campaign campaign)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = campaign.Id,
|
||||
GmUserId = campaign.GmUserId,
|
||||
Name = campaign.Name,
|
||||
Ruleset = campaign.Ruleset,
|
||||
Version = campaign.Version
|
||||
};
|
||||
}
|
||||
|
||||
private static Character CloneCharacter(Character character)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = character.Id,
|
||||
OwnerUserId = character.OwnerUserId,
|
||||
CampaignId = character.CampaignId,
|
||||
Name = character.Name
|
||||
};
|
||||
}
|
||||
|
||||
private static Skill CloneSkill(Skill skill)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = skill.Id,
|
||||
CharacterId = skill.CharacterId,
|
||||
SkillGroupId = skill.SkillGroupId,
|
||||
Name = skill.Name,
|
||||
DiceRollDefinition = skill.DiceRollDefinition,
|
||||
WildDice = skill.WildDice,
|
||||
AllowFumble = skill.AllowFumble,
|
||||
FumbleRange = skill.FumbleRange
|
||||
};
|
||||
}
|
||||
|
||||
private static SkillGroup CloneSkillGroup(SkillGroup skillGroup)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = skillGroup.Id,
|
||||
CharacterId = skillGroup.CharacterId,
|
||||
Name = skillGroup.Name,
|
||||
DiceRollDefinition = skillGroup.DiceRollDefinition,
|
||||
WildDice = skillGroup.WildDice,
|
||||
AllowFumble = skillGroup.AllowFumble,
|
||||
FumbleRange = skillGroup.FumbleRange
|
||||
};
|
||||
}
|
||||
|
||||
private static RollLogEntry CloneRollLogEntry(RollLogEntry entry)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = entry.Id,
|
||||
CampaignId = entry.CampaignId,
|
||||
CharacterId = entry.CharacterId,
|
||||
SkillId = entry.SkillId,
|
||||
RollerUserId = entry.RollerUserId,
|
||||
Visibility = entry.Visibility,
|
||||
Result = entry.Result,
|
||||
Breakdown = entry.Breakdown,
|
||||
Dice = entry.Dice,
|
||||
TimestampUtc = entry.TimestampUtc
|
||||
};
|
||||
}
|
||||
|
||||
private const int CampaignLogHistoryWindowSize = 100;
|
||||
private const int CampaignLogLivePageSize = 25;
|
||||
private const string CustomRollBreakdownSeparator = " => ";
|
||||
private static readonly Guid CustomRollSkillId = Guid.Empty;
|
||||
private const string CustomRollLabel = "Custom roll";
|
||||
private static readonly JsonSerializerOptions DiceJsonOptions = RpgRollerJson.CreateSerializerOptions();
|
||||
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 Dictionary<Guid, Campaign> m_CampaignsById;
|
||||
private readonly Dictionary<Guid, GameCampaignStateTracker> m_CampaignStateById;
|
||||
private readonly Dictionary<Guid, Character> m_CharactersById;
|
||||
private readonly IDiceRoller m_DiceRoller;
|
||||
private readonly object m_Gate = new();
|
||||
private readonly object m_Gate;
|
||||
private readonly IPasswordHasher<UserAccount> m_PasswordHasher;
|
||||
private readonly List<RollLogEntry> m_RollLog = [];
|
||||
private readonly Dictionary<string, UserSession> m_SessionsByToken = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<Guid, SkillGroup> m_SkillGroupsById = [];
|
||||
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; } = [];
|
||||
}
|
||||
private readonly GamePersistenceService m_PersistenceService;
|
||||
private readonly List<RollLogEntry> m_RollLog;
|
||||
private readonly Dictionary<string, UserSession> m_SessionsByToken;
|
||||
private readonly Dictionary<Guid, SkillGroup> m_SkillGroupsById;
|
||||
private readonly Dictionary<Guid, Skill> m_SkillsById;
|
||||
private readonly GameStateStore m_StateStore;
|
||||
private readonly Dictionary<string, Guid> m_UserIdsByUsername;
|
||||
private readonly Dictionary<Guid, UserAccount> m_UsersById;
|
||||
}
|
||||
|
||||
99
RpgRoller/Services/GameStateCloneFactory.cs
Normal file
99
RpgRoller/Services/GameStateCloneFactory.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Services;
|
||||
|
||||
public static class GameStateCloneFactory
|
||||
{
|
||||
public static UserAccount CloneUser(UserAccount user)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = user.Id,
|
||||
Username = user.Username,
|
||||
UsernameNormalized = user.UsernameNormalized,
|
||||
PasswordHash = user.PasswordHash,
|
||||
DisplayName = user.DisplayName,
|
||||
Roles = user.Roles,
|
||||
ActiveCharacterId = user.ActiveCharacterId
|
||||
};
|
||||
}
|
||||
|
||||
public static UserSession CloneSession(UserSession session)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Token = session.Token,
|
||||
UserId = session.UserId,
|
||||
CreatedAtUtc = session.CreatedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
public static Campaign CloneCampaign(Campaign campaign)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = campaign.Id,
|
||||
GmUserId = campaign.GmUserId,
|
||||
Name = campaign.Name,
|
||||
Ruleset = campaign.Ruleset,
|
||||
Version = campaign.Version
|
||||
};
|
||||
}
|
||||
|
||||
public static Character CloneCharacter(Character character)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = character.Id,
|
||||
OwnerUserId = character.OwnerUserId,
|
||||
CampaignId = character.CampaignId,
|
||||
Name = character.Name
|
||||
};
|
||||
}
|
||||
|
||||
public static Skill CloneSkill(Skill skill)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = skill.Id,
|
||||
CharacterId = skill.CharacterId,
|
||||
SkillGroupId = skill.SkillGroupId,
|
||||
Name = skill.Name,
|
||||
DiceRollDefinition = skill.DiceRollDefinition,
|
||||
WildDice = skill.WildDice,
|
||||
AllowFumble = skill.AllowFumble,
|
||||
FumbleRange = skill.FumbleRange
|
||||
};
|
||||
}
|
||||
|
||||
public static SkillGroup CloneSkillGroup(SkillGroup skillGroup)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = skillGroup.Id,
|
||||
CharacterId = skillGroup.CharacterId,
|
||||
Name = skillGroup.Name,
|
||||
DiceRollDefinition = skillGroup.DiceRollDefinition,
|
||||
WildDice = skillGroup.WildDice,
|
||||
AllowFumble = skillGroup.AllowFumble,
|
||||
FumbleRange = skillGroup.FumbleRange
|
||||
};
|
||||
}
|
||||
|
||||
public static RollLogEntry CloneRollLogEntry(RollLogEntry entry)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = entry.Id,
|
||||
CampaignId = entry.CampaignId,
|
||||
CharacterId = entry.CharacterId,
|
||||
SkillId = entry.SkillId,
|
||||
RollerUserId = entry.RollerUserId,
|
||||
Visibility = entry.Visibility,
|
||||
Result = entry.Result,
|
||||
Breakdown = entry.Breakdown,
|
||||
Dice = entry.Dice,
|
||||
TimestampUtc = entry.TimestampUtc
|
||||
};
|
||||
}
|
||||
}
|
||||
25
RpgRoller/Services/GameStateStore.cs
Normal file
25
RpgRoller/Services/GameStateStore.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Services;
|
||||
|
||||
public sealed class GameStateStore
|
||||
{
|
||||
public object Gate { get; } = new();
|
||||
public Dictionary<Guid, Campaign> CampaignsById { get; } = [];
|
||||
public Dictionary<Guid, GameCampaignStateTracker> CampaignStateById { get; } = [];
|
||||
public Dictionary<Guid, Character> CharactersById { get; } = [];
|
||||
public List<RollLogEntry> RollLog { get; } = [];
|
||||
public Dictionary<string, UserSession> SessionsByToken { get; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<Guid, SkillGroup> SkillGroupsById { get; } = [];
|
||||
public Dictionary<Guid, Skill> SkillsById { get; } = [];
|
||||
public Dictionary<string, Guid> UserIdsByUsername { get; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<Guid, UserAccount> UsersById { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class GameCampaignStateTracker
|
||||
{
|
||||
public long TotalVersion { get; set; } = 1;
|
||||
public long RosterVersion { get; set; } = 1;
|
||||
public long LogVersion { get; set; } = 1;
|
||||
public Dictionary<Guid, long> CharacterVersions { get; } = [];
|
||||
}
|
||||
Reference in New Issue
Block a user