Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -378,3 +378,5 @@ FodyWeavers.xsd
/Maple2.Server.Game/DebugTriggers
.idea/.idea.Maple2/.idea/AugmentWebviewStateStore.xml
/.idea/.idea.Maple2/.idea
.claude/settings.local.json
.mcp.json
2 changes: 1 addition & 1 deletion Maple2.Database/Model/Account.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ internal class Account {
public bool ActiveGoldPass { get; set; }

public DateTime CreationTime { get; set; }
public DateTime LastModified { get; set; }
public DateTime LastModified { get; init; }

public bool Online { get; set; }
public string Permissions { get; set; }
Expand Down
2 changes: 1 addition & 1 deletion Maple2.Database/Model/Character.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ internal class Character {
public required Mastery Mastery { get; set; }
public DateTime DeleteTime { get; set; }
public DateTime CreationTime { get; set; }
public DateTime LastModified { get; set; }
public DateTime LastModified { get; init; }

[return: NotNullIfNotNull(nameof(other))]
public static implicit operator Character?(Maple2.Model.Game.Character? other) {
Expand Down
2 changes: 1 addition & 1 deletion Maple2.Database/Model/CharacterUnlock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ internal class CharacterUnlock {
public required IDictionary<int, byte> CollectedItems { get; set; }
public required InventoryExpand Expand { get; set; }
public short HairSlotExpand { get; set; }
public DateTime LastModified { get; init; }
public DateTime LastModified { get; set; }

public static implicit operator CharacterUnlock(Maple2.Model.Game.Unlock? other) {
return other == null ? new CharacterUnlock {
Expand Down
154 changes: 150 additions & 4 deletions Maple2.Database/Storage/Game/GameStorage.User.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
using Maple2.Database.Extensions;
using System.Text.Json;
using Maple2.Database.Extensions;
using Maple2.Database.Model;
using Maple2.Model.Enum;
using Maple2.Model.Game;
using Maple2.Model.Metadata;
using Maple2.Server.Game.Manager.Config;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.Logging;
using Account = Maple2.Model.Game.Account;
using Character = Maple2.Model.Game.Character;
Expand Down Expand Up @@ -207,6 +210,25 @@ from outdoor in plot.DefaultIfEmpty()
return home;
}

public (DateTime CharacterLastModified, DateTime AccountLastModified, DateTime UnlockLastModified)? GetLastModifiedTimestamps(long characterId) {
var result = Context.Character.Where(character => character.Id == characterId)
.Join(Context.Account, character => character.AccountId, account => account.Id, (character, account) => new {
character,
account,
})
.Join(Context.CharacterUnlock, @t => @t.character.Id, unlock => unlock.CharacterId, (@t, unlock) => new {
CharacterLastModified = @t.character.LastModified,
AccountLastModified = @t.account.LastModified,
UnlockLastModified = unlock.LastModified,
})
.AsNoTracking()
.FirstOrDefault();
if (result == null) {
return null;
}
return (result.CharacterLastModified, result.AccountLastModified, result.UnlockLastModified);
}

// We pass in objectId only for Player initialization.
public Player? LoadPlayer(long accountId, long characterId, int objectId, short channel) {
Context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.TrackAll;
Expand Down Expand Up @@ -278,7 +300,7 @@ from outdoor in plot.DefaultIfEmpty()
}

public bool SavePlayer(Player player) {
Console.WriteLine($"> Begin Save... {Context.ContextId}");
Logger.LogInformation("> Begin Save... {ContextId}:{CharacterId}", Context.ContextId, player.Character.Id);

Model.Account account = player.Account;
account.Currency = new AccountCurrency {
Expand Down Expand Up @@ -314,8 +336,132 @@ public bool SavePlayer(Player player) {
unlock.CharacterId = character.Id;
Context.Update(unlock);

Context.ChangeTracker.Entries().DisplayStates();
return Context.TrySaveChanges();
bool saved = false;
int attempt = 0;
const int maxAttempts = 5;

while (!saved && attempt < maxAttempts) {
try {
attempt++;
Context.SaveChanges();
saved = true;
} catch (DbUpdateConcurrencyException ex) {
Logger.LogWarning("> Concurrency conflict (attempt {Attempt}) for CharacterId={CharacterId}", attempt, player.Character.Id);
foreach (EntityEntry entry in ex.Entries) {
string entityName = entry.Metadata.ClrType.Name;
if (entry.Entity is not Model.Account && entry.Entity is not Model.Character && entry.Entity is not CharacterUnlock) {
// Intentionally re-throw for unsupported entity types as fail-fast behavior during development.
// SavePlayer only handles concurrency conflicts for Account, Character, and CharacterUnlock.
// If other entities are unexpectedly involved, this indicates a logic error that should be caught immediately.
Logger.LogInformation(" Unsupported concurrency entity {EntityName}, rethrowing.", entityName);
throw;
}

PropertyValues? databaseValues = entry.GetDatabaseValues();
if (databaseValues == null) {
Logger.LogInformation(" Entity {EntityName} appears deleted in DB. Aborting save.", entityName);
return false;
}
PropertyValues proposedValues = entry.CurrentValues;

Logger.LogWarning(" Diff for {EntityName}:", entityName);
foreach (IProperty property in proposedValues.Properties) {
if (property.IsConcurrencyToken) {
object? originalValue = entry.OriginalValues[property];
object? currentValue = proposedValues[property];
object? databaseValue2 = databaseValues[property];
Logger.LogError(" {PropertyName}: original='{S}' current='{FormatValue1}' db='{S1}' <concurrency token>", property.Name, FormatValue(originalValue), FormatValue(currentValue), FormatValue(databaseValue2));
continue;
}
if (property.Name.Equals("Password", StringComparison.OrdinalIgnoreCase)) {
continue;
}

object? proposedValue = proposedValues[property];
object? databaseValue = databaseValues[property];

// Handle CreationTime as immutable: always trust database value and suppress logging
if (property.Name.Equals("CreationTime", StringComparison.OrdinalIgnoreCase)) {
if (proposedValue is DateTime propCt && databaseValue is DateTime dbCt) {
// If they differ only by fractional seconds / timezone, normalize by taking db value
if (propCt != dbCt) {
proposedValues[property] = dbCt;
}
} else if (databaseValue != null) {
proposedValues[property] = databaseValue; // non-DateTime edge case
}
continue; // don't log CreationTime differences
}

if (property.Name.Contains("CreationTime", StringComparison.OrdinalIgnoreCase) &&
proposedValue is DateTime pvDt && pvDt == default &&
databaseValue is DateTime dbDt && dbDt != default) {
proposedValues[property] = databaseValue;
continue;
}

if (IsJsonStructurallyEqual(property.Name, proposedValue, databaseValue)) {
// Logger.LogWarning($" {property.Name}: proposed and db are structurally equal JSON. proposed='{FormatValue(proposedValue)}' db='{FormatValue(databaseValue)}'");
continue;
}

if (!Equals(proposedValue, databaseValue)) {
Logger.LogInformation(" {PropertyName}: proposed='{S}' db='{FormatValue1}'", property.Name, FormatValue(proposedValue), FormatValue(databaseValue));
}
}

entry.OriginalValues.SetValues(databaseValues);
}
} catch (Exception ex) {
Logger.LogError("> Save failed (non-concurrency) CharacterId={CharacterId} attempt={Attempt}\n{Exception}", player.Character.Id, attempt, ex);
return false;
}
}
if (!saved) {
Logger.LogError("> Save failed after {MaxAttempts} attempts CharacterId={CharacterId}", maxAttempts, player.Character.Id);
return false;
}

// get updated values after save
(DateTime CharacterLastModified, DateTime AccountLastModified, DateTime UnlockLastModified)? newPlayer = GetLastModifiedTimestamps(character.Id);
if (newPlayer == null) {
Logger.LogError("> Save succeeded but failed to fetch updated timestamps CharacterId={CharacterId}", player.Character.Id);
return false;
}
player.Account.LastModified = newPlayer.Value.AccountLastModified;
player.Character.LastModified = newPlayer.Value.CharacterLastModified;
player.Unlock.LastModified = newPlayer.Value.UnlockLastModified;

Logger.LogInformation("> Save complete {ContextId}:{CharacterId}", Context.ContextId, player.Character.Id);
return true;
}

// Added helper methods for JSON diff suppression & formatting
private static readonly HashSet<string> JsonNoiseProperties = new(StringComparer.OrdinalIgnoreCase) {
"Cooldown",
"Currency",
"Experience",
"Mastery",
"Profile",
};

private static bool IsJsonStructurallyEqual(string propertyName, object? proposed, object? database) {
if (!JsonNoiseProperties.Contains(propertyName)) return false;
if (proposed == null && database == null) return true;
if (proposed == null || database == null) return false;
try {
string p = JsonSerializer.Serialize(proposed);
string d = JsonSerializer.Serialize(database);
return string.Equals(p, d, StringComparison.Ordinal);
} catch { return false; }
}

private static string FormatValue(object? value) {
if (value == null) return "<null>";
if (value is DateTime dt) return dt.ToString("O");
Type t = value.GetType();
if (t.IsPrimitive || value is string) return value.ToString() ?? string.Empty;
return t.Name;
}

public bool SaveCharacter(Character character) {
Expand Down
2 changes: 1 addition & 1 deletion Maple2.Model/Game/User/Account.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ namespace Maple2.Model.Game;

public class Account {
#region Immutable
public DateTime LastModified { get; init; }
public DateTime LastModified { get; set; }
public long Id { get; init; }

public required string Username { get; init; }
Expand Down
2 changes: 1 addition & 1 deletion Maple2.Model/Game/User/Character.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
public class Character {
#region Immutable
public long CreationTime { get; init; }
public DateTime LastModified { get; init; }
public DateTime LastModified { get; set; }

public long Id { get; init; }
public long AccountId { get; init; }
Expand Down Expand Up @@ -48,7 +48,7 @@
public List<long> ClubIds = [];
public required Mastery Mastery;
public AchievementInfo AchievementInfo;
public MarriageInfo MarriageInfo;

Check warning on line 51 in Maple2.Model/Game/User/Character.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field 'MarriageInfo' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.
public readonly Dictionary<int, DungeonEnterLimit> DungeonEnterLimits = [];
public short DeathCount;
public long DeathTick;
Expand Down
2 changes: 1 addition & 1 deletion Maple2.Model/Game/User/Player.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public Player(Account account, Character character, int objectId) {
}

public class Unlock {
public DateTime LastModified { get; init; }
public DateTime LastModified { get; set; }

public IDictionary<InventoryType, short> Expand { get; init; } = new Dictionary<InventoryType, short>();
public short HairSlotExpand;
Expand Down
Loading