feat(castle-siege): adopt the upstream Castle Siege data model and persistence
Brings in the database layer of upstream OpenMU PRs #754 and #860 without touching AdaMu's working Castle Siege gameplay. This is purely additive: the existing 5-phase implementation still runs exactly as before. What is included: - DataModel: CastleSiegeState (the original Season 6 values 0-9, which are exactly what the game client's CASTLESIEGE_STATE enum expects), CastleSiegeJoinSide, and the zone/NPC/upgrade definition types. - Entities: CastleSiegeData, CastleSiegeGuildRegistration, CastleSiegeNpcState. These identify a guild by its persistent Guid rather than by name. - Generated persistence: 8 BasicModel + 8 EntityFramework model classes, CastleSiegeExtensions, and the regenerated ExtendedTypeContext, MapsterConfigurator and GameConfiguration partials. - Migrations: 20260730194321_AddCastleSiege and 20260801162427_ConfigureCastleSiegePersistence, plus the model snapshot. - EntityDataContext gains the two DbSets and the five model registrations. - EntityFrameworkContextBase only publishes configuration changes for entities in the configuration schema, so siege state writes are no longer broadcast as configuration changes. AdaMu-specific adaptations: - UpdateVersion.AddCastleSiegeData is 105, not upstream's 100. AdaMu already ships 95-104, and the applied-update bookkeeping is keyed on this value, so a collision would skip or re-run updates on live databases. - CastleSiegeInitializer does not seed a weekly StateSchedule. Upstream drives the cycle from a fixed Saturday schedule; AdaMu drives it manually from CastleSiegeEventPlugIn and the AdminPanel, so the schedule is left empty and nothing reads it. The seeded NPC definitions match AdaMu's existing hard-coded coordinates exactly (6 gates, the two crown switches and the crown), and additionally provide 4 guardian statues, 6 guardsmen and real gate/statue hit point tables that the current implementation does not have yet. Two pre-existing migrations were restyled by upstream (copyright header, using placement, trailing comma). No functional change. Verified: full server build succeeds with 0 errors.
This commit is contained in:
@@ -68,6 +68,55 @@ internal class EntityFrameworkContextBase : IContext
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask<bool> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// A player's entities can be mutated by game logic on a flow that is not serialized against
|
||||
// this save (for example item destruction on an attacker's thread during combat). Such a
|
||||
// concurrent mutation makes change detection throw while it enumerates a tracked collection.
|
||||
// The mutation is a single, quick operation, so a bounded retry lands on a stable moment
|
||||
// instead of failing the whole save - which would otherwise leave the session unpersisted and
|
||||
// roll the player back on relog.
|
||||
const int maxAttempts = 3;
|
||||
var attempt = 0;
|
||||
while (true)
|
||||
{
|
||||
attempt++;
|
||||
try
|
||||
{
|
||||
return await this.SaveChangesCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxAttempts && IsTransientConcurrencyConflict(ex))
|
||||
{
|
||||
this._logger.LogWarning(ex, "Transient concurrency conflict while saving (attempt {Attempt}/{MaxAttempts}); retrying.", attempt, maxAttempts);
|
||||
await Task.Delay(attempt * 10, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the exception is a transient conflict caused by a concurrent entity mutation
|
||||
/// racing this save, and is therefore worth retrying.
|
||||
/// </summary>
|
||||
/// <param name="exception">The exception thrown by the save.</param>
|
||||
/// <returns><c>true</c> if the save should be retried.</returns>
|
||||
private static bool IsTransientConcurrencyConflict(Exception exception)
|
||||
{
|
||||
// A concurrent entity mutation racing this save corrupts the change tracker mid-enumeration.
|
||||
// Depending on exactly where change detection was, it surfaces as one of several types - a
|
||||
// modified collection (InvalidOperationException), a transiently-null internal key
|
||||
// (ArgumentNullException/NullReferenceException), or an out-of-range index. All are transient:
|
||||
// the racing mutation is a single quick operation, so a bounded retry lands on a stable moment.
|
||||
// A genuinely persistent error of the same type is not masked - it rethrows once the retries
|
||||
// are exhausted. The deterministic serialization (per-player persistence lock) is the primary
|
||||
// guard; this retry only needs to absorb the rare, bursty sources that lock isn't held for.
|
||||
return exception is DbUpdateConcurrencyException
|
||||
or InvalidOperationException
|
||||
or ArgumentNullException
|
||||
or NullReferenceException
|
||||
or IndexOutOfRangeException
|
||||
or KeyNotFoundException;
|
||||
}
|
||||
|
||||
private async ValueTask<bool> SaveChangesCoreAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var l = await this._lock.LockAsync();
|
||||
|
||||
@@ -252,6 +301,14 @@ internal class EntityFrameworkContextBase : IContext
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether changes of an entity type are published as configuration changes.
|
||||
/// </summary>
|
||||
/// <param name="entityType">The entity type.</param>
|
||||
/// <returns><see langword="true"/> when the entity belongs to the configuration schema.</returns>
|
||||
internal static bool PublishesConfigurationChanges(IReadOnlyEntityType entityType)
|
||||
=> entityType.GetSchema() == SchemaNames.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Releases unmanaged and - optionally - managed resources.
|
||||
/// </summary>
|
||||
@@ -343,7 +400,9 @@ internal class EntityFrameworkContextBase : IContext
|
||||
}
|
||||
|
||||
var changedEntries = this.Context.ChangeTracker.Entries()
|
||||
.Where(entity => entity.State != EntityState.Unchanged).ToList();
|
||||
.Where(entity => entity.State != EntityState.Unchanged
|
||||
&& PublishesConfigurationChanges(entity.Metadata))
|
||||
.ToList();
|
||||
foreach (var entry in changedEntries)
|
||||
{
|
||||
var (parent, parentCollectionNavigation) = this.GetParentInformation(entry);
|
||||
@@ -413,4 +472,4 @@ internal class EntityFrameworkContextBase : IContext
|
||||
|
||||
return (parent ?? parentId, parentCollectionNavigation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user