Skip to content

Commit a1e0086

Browse files
authored
Feature/simplify data capture (#15)
* feat: ContentHash on DocumentMetadata with canonical JSON normalization - Add ContentHash field to DocumentMetadataEntity (BLite + EF Core) - Add supporting indexes (BLite HasIndex + EF Core IX_DocumentMetadata_Collection_Key_ContentHash) - Normalize PayloadJson in oplog on write (key-sorted, compact) for reliable cross-node comparisons - EntityMappers: NormalizeJson(), ComputeContentHash() (CDC path), ComputeContentHashFromNormalized() (oplog fast path) - EfCoreDocumentStore: same helpers duplicated locally (no BLite dependency) - Populate ContentHash in all metadata write sites: BLiteDocumentStore + EfCoreDocumentStore * Add hero classes, MP and magic combat Introduce a HeroClass system and MP/magic mechanics. Adds a new HeroClass enum and HeroClassFactory with per-class profiles and ApplyInitialStats. Extend Hero with HeroClass, Mp, MaxMp and MagicAttack. Update GameService to present class choices (with emoji and stat range table), apply initial class stats, and display class/emojis across UI (rules, lists, stats, leaderboard). Add MP tracking: show MP in status, inn restores HP and caps MP at 80%, MP regenerates on victory, and level-ups now use per-class randomized growth affecting HP/MP/attack/defense/magic. Add a Fireball spell (15 MP) to combat and adjust prompts/markup accordingly. * Store Hero class as int (ClassId) Replace the Hero.HeroClass enum property with an int-backed ClassId and a computed Class property; update all GameService references to use h.Class and HeroClassFactory to use the new property. This converts enum storage to an integer representation (e.g. for persistence) and updates display/leveling code accordingly. Note: ApplyInitialStats now assigns hero.Class (the new computed property) — ensure this updates ClassId or add a setter as needed. * refactor: EF Core CDC via ChangeTracker SavingChanges/SavedChanges + ContentHash dedup - Add IVectorClockService to EfCoreDocumentStore constructor (parity with BLite) - Subscribe SavingChanges to capture app entity changes (CapturePendingChanges) - Subscribe SavedChanges to fire async OnLocalChangeDetectedAsync per entity - OnLocalChangeDetectedAsync deduplicates by ContentHash (same logic as BLite) - PutDocumentInternalAsync now writes ContentHash to DocumentMetadata (sync path) - CreateOplogEntryAsync now calls _vectorClock.Update(oplogEntry) - Remove _remoteSyncGuard semaphore and all WaitAsync/Release wrappers - Remove BeginRemoteSync() and RemoteSyncScope (replaced by hash dedup) - Add abstract GetCollectionAndKey() for subclasses to declare their entity mapping - Update SampleEfCoreDocumentStore: IVectorClockService param + GetCollectionAndKey - Update EfCoreStoreExportImportTests to pass vectorClock to document store * Add MonoGame demo and pending-change infrastructure Introduce a MonoGame demo project (samples/EntglDb.Demo.Game.MonoGame) with game entrypoint, screens, UI widgets, fonts/content pipeline and Program integration; add the project to the solution. Add pending-change APIs and persistence plumbing: IPendingChangesService, IPendingChangesFlushService, PendingChange model and PendingChangeEntity, BLite pending-changes service and flush service, EntglDbMetaContext, and updates to BLite stores/metadata/oplog. Add an EF Core save changes interceptor (EntglDbSaveChangesInterceptor) and miscellaneous changes to SyncOrchestrator, sample projects and tests to wire up the new functionality. Remove legacy pack/stress/start batch scripts. * chore(release): 2.1.0
1 parent e073d53 commit a1e0086

75 files changed

Lines changed: 3316 additions & 988 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616

1717
---
1818

19+
<a name="2.1.0"></a>
20+
## [2.1.0](https://www.github.com/EntglDb/EntglDb.Net/releases/tag/v2.1.0) (2026-03-19)
21+
22+
### Features
23+
24+
* ContentHash on DocumentMetadata with canonical JSON normalization ([dfa856f](https://www.github.com/EntglDb/EntglDb.Net/commit/dfa856f766907e6ca40de63146eaed0533cc1a39))
25+
1926
<a name="2.0.1"></a>
2027
## [2.0.1](https://www.github.com/EntglDb/EntglDb.Net/releases/tag/v2.0.1) (2026-03-18)
2128

EntglDb.Net.sln

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntglDb.Sample.Shared.Tests
3939
EndProject
4040
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntglDb.Demo.Game", "samples\EntglDb.Demo.Game\EntglDb.Demo.Game.csproj", "{2C9D4FD0-2DCD-495F-B121-06346477A3EE}"
4141
EndProject
42+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntglDb.Demo.Game.MonoGame", "samples\EntglDb.Demo.Game.MonoGame\EntglDb.Demo.Game.MonoGame.csproj", "{2C386189-C88A-D7F4-3821-C7AAFBB52577}"
43+
EndProject
4244
Global
4345
GlobalSection(SolutionConfigurationPlatforms) = preSolution
4446
Debug|Any CPU = Debug|Any CPU
@@ -230,6 +232,18 @@ Global
230232
{2C9D4FD0-2DCD-495F-B121-06346477A3EE}.Release|x64.Build.0 = Release|Any CPU
231233
{2C9D4FD0-2DCD-495F-B121-06346477A3EE}.Release|x86.ActiveCfg = Release|Any CPU
232234
{2C9D4FD0-2DCD-495F-B121-06346477A3EE}.Release|x86.Build.0 = Release|Any CPU
235+
{2C386189-C88A-D7F4-3821-C7AAFBB52577}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
236+
{2C386189-C88A-D7F4-3821-C7AAFBB52577}.Debug|Any CPU.Build.0 = Debug|Any CPU
237+
{2C386189-C88A-D7F4-3821-C7AAFBB52577}.Debug|x64.ActiveCfg = Debug|Any CPU
238+
{2C386189-C88A-D7F4-3821-C7AAFBB52577}.Debug|x64.Build.0 = Debug|Any CPU
239+
{2C386189-C88A-D7F4-3821-C7AAFBB52577}.Debug|x86.ActiveCfg = Debug|Any CPU
240+
{2C386189-C88A-D7F4-3821-C7AAFBB52577}.Debug|x86.Build.0 = Debug|Any CPU
241+
{2C386189-C88A-D7F4-3821-C7AAFBB52577}.Release|Any CPU.ActiveCfg = Release|Any CPU
242+
{2C386189-C88A-D7F4-3821-C7AAFBB52577}.Release|Any CPU.Build.0 = Release|Any CPU
243+
{2C386189-C88A-D7F4-3821-C7AAFBB52577}.Release|x64.ActiveCfg = Release|Any CPU
244+
{2C386189-C88A-D7F4-3821-C7AAFBB52577}.Release|x64.Build.0 = Release|Any CPU
245+
{2C386189-C88A-D7F4-3821-C7AAFBB52577}.Release|x86.ActiveCfg = Release|Any CPU
246+
{2C386189-C88A-D7F4-3821-C7AAFBB52577}.Release|x86.Build.0 = Release|Any CPU
233247
EndGlobalSection
234248
GlobalSection(SolutionProperties) = preSolution
235249
HideSolutionNode = FALSE
@@ -250,6 +264,7 @@ Global
250264
{B3D45E8B-F145-FB6A-6856-3A5750A16BFB} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
251265
{383C9975-DA0C-4511-2282-8EB531AB6999} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
252266
{2C9D4FD0-2DCD-495F-B121-06346477A3EE} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
267+
{2C386189-C88A-D7F4-3821-C7AAFBB52577} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
253268
EndGlobalSection
254269
GlobalSection(ExtensibilityGlobals) = postSolution
255270
SolutionGuid = {C8A8F3D5-CBE2-416E-9B89-3F18EEB4F884}

pack.bat

Lines changed: 0 additions & 15 deletions
This file was deleted.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
namespace EntglDb.Demo.Game.MonoGame;
2+
3+
/// <summary>ASCII symbols for hero classes — SpriteFont-safe replacements for emoji.</summary>
4+
internal static class ClassSymbol
5+
{
6+
public static string For(HeroClass cls) => cls switch
7+
{
8+
HeroClass.Warrior => "[W]",
9+
HeroClass.Mage => "[M]",
10+
HeroClass.Rogue => "[R]",
11+
HeroClass.Paladin => "[P]",
12+
HeroClass.Ranger => "[A]",
13+
HeroClass.Necromancer => "[N]",
14+
_ => "[?]",
15+
};
16+
17+
/// <summary>
18+
/// Replaces any character outside the printable ASCII range (32-126) with
19+
/// a safe substitute so SpriteBatch.DrawString never throws.
20+
/// Common replacements keep the text readable (em-dash -> hyphen, etc.).
21+
/// </summary>
22+
public static string Sanitize(string text)
23+
{
24+
if (string.IsNullOrEmpty(text)) return text;
25+
var sb = new System.Text.StringBuilder(text.Length);
26+
foreach (char c in text)
27+
{
28+
if (c is >= ' ' and <= '~')
29+
sb.Append(c); // printable ASCII — keep as-is
30+
else if (c == '\u2014' || c == '\u2013')
31+
sb.Append('-'); // em-dash / en-dash -> hyphen
32+
else if (c == '\u2018' || c == '\u2019')
33+
sb.Append('\''); // curly single quotes
34+
else if (c == '\u201C' || c == '\u201D')
35+
sb.Append('"'); // curly double quotes
36+
else
37+
sb.Append('?'); // unknown non-ASCII
38+
}
39+
return sb.ToString();
40+
}
41+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
2+
#----------------------------- Global Properties ----------------------------#
3+
4+
/outputDir:bin/$(Platform)/$(Configuration)/
5+
/intermediateDir:obj/$(Platform)/$(Configuration)/
6+
/platform:DesktopGL
7+
/config:
8+
/profile:Reach
9+
/compress:False
10+
11+
#-------------------------------- References --------------------------------#
12+
13+
14+
#---------------------------------- Content ---------------------------------#
15+
16+
#begin Fonts/DefaultFont.spritefont
17+
/importer:FontDescriptionImporter
18+
/processor:FontDescriptionProcessor
19+
/processorParam:PremultiplyAlpha=True
20+
/processorParam:TextureFormat=Compressed
21+
/build:Fonts/DefaultFont.spritefont
22+
23+
#begin Fonts/TitleFont.spritefont
24+
/importer:FontDescriptionImporter
25+
/processor:FontDescriptionProcessor
26+
/processorParam:PremultiplyAlpha=True
27+
/processorParam:TextureFormat=Compressed
28+
/build:Fonts/TitleFont.spritefont
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
DefaultFont — used for menus, stats, and general UI text.
4+
-->
5+
<XnaContent xmlns:Graphics="Microsoft.Xna.Framework.Content.Pipeline.Graphics">
6+
<Asset Type="Graphics:FontDescription">
7+
<FontName>Arial</FontName>
8+
<Size>18</Size>
9+
<Spacing>2</Spacing>
10+
<UseKerning>true</UseKerning>
11+
<Style>Regular</Style>
12+
<CharacterRegions>
13+
<CharacterRegion>
14+
<Start>&#32;</Start>
15+
<End>&#126;</End>
16+
</CharacterRegion>
17+
</CharacterRegions>
18+
</Asset>
19+
</XnaContent>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
TitleFont — larger font used for screen titles and the game banner.
4+
-->
5+
<XnaContent xmlns:Graphics="Microsoft.Xna.Framework.Content.Pipeline.Graphics">
6+
<Asset Type="Graphics:FontDescription">
7+
<FontName>Arial</FontName>
8+
<Size>32</Size>
9+
<Spacing>2</Spacing>
10+
<UseKerning>true</UseKerning>
11+
<Style>Bold</Style>
12+
<CharacterRegions>
13+
<CharacterRegion>
14+
<Start>&#32;</Start>
15+
<End>&#126;</End>
16+
</CharacterRegion>
17+
</CharacterRegions>
18+
</Asset>
19+
</XnaContent>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using EntglDb.Demo.Game.MonoGame.Screens;
2+
using Microsoft.Xna.Framework;
3+
using Microsoft.Xna.Framework.Graphics;
4+
5+
namespace EntglDb.Demo.Game.MonoGame;
6+
7+
/// <summary>MonoGame entry point — loads fonts and drives the screen stack.</summary>
8+
public sealed class DungeonGame : Microsoft.Xna.Framework.Game
9+
{
10+
private readonly GraphicsDeviceManager _graphics;
11+
private SpriteBatch _spriteBatch = null!;
12+
private SpriteFont _defaultFont = null!;
13+
private SpriteFont _titleFont = null!;
14+
private readonly ScreenManager _screenManager = new();
15+
16+
private readonly GameEngine _engine;
17+
private readonly string _nodeId;
18+
19+
public DungeonGame(GameEngine engine, string nodeId)
20+
{
21+
_engine = engine;
22+
_nodeId = nodeId;
23+
_graphics = new GraphicsDeviceManager(this)
24+
{
25+
PreferredBackBufferWidth = 1024,
26+
PreferredBackBufferHeight = 768
27+
};
28+
Content.RootDirectory = "Content";
29+
IsMouseVisible = true;
30+
}
31+
32+
protected override void LoadContent()
33+
{
34+
_spriteBatch = new SpriteBatch(GraphicsDevice);
35+
_defaultFont = Content.Load<SpriteFont>("Fonts/DefaultFont");
36+
_titleFont = Content.Load<SpriteFont>("Fonts/TitleFont");
37+
38+
_screenManager.Push(new MainMenuScreen(_engine, _nodeId, _titleFont, _defaultFont));
39+
}
40+
41+
protected override void Update(GameTime gameTime)
42+
{
43+
if (_screenManager.Current == null)
44+
{
45+
Exit();
46+
return;
47+
}
48+
_screenManager.Update(gameTime);
49+
base.Update(gameTime);
50+
}
51+
52+
protected override void Draw(GameTime gameTime)
53+
{
54+
GraphicsDevice.Clear(new Color(15, 15, 25));
55+
_spriteBatch.Begin(samplerState: Microsoft.Xna.Framework.Graphics.SamplerState.PointClamp);
56+
_screenManager.Draw(_spriteBatch, gameTime);
57+
_spriteBatch.End();
58+
base.Draw(gameTime);
59+
}
60+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>WinExe</OutputType>
5+
<!-- MonoGame 3.8.2 NuGet targets net8.0 but runs on net10.0 (pure managed code, NU1701 suppressed) -->
6+
<TargetFramework>net10.0</TargetFramework>
7+
<Nullable>enable</Nullable>
8+
<ImplicitUsings>enable</ImplicitUsings>
9+
<RootNamespace>EntglDb.Demo.Game.MonoGame</RootNamespace>
10+
<ApplicationIcon />
11+
<StartupObject />
12+
<!-- Suppress the platform warning produced by MonoGame NuGet targeting net8.0 -->
13+
<NoWarn>NU1701</NoWarn>
14+
<!-- Override the MGCB command to use the full path to the global tool so
15+
MSBuild can find it even when dotnet tools folder is not on PATH -->
16+
<_Command>"$([System.IO.Path]::Combine($([System.Environment]::GetFolderPath(SpecialFolder.UserProfile)), '.dotnet', 'tools', 'mgcb.exe'))"</_Command>
17+
</PropertyGroup>
18+
19+
<!-- Core game logic — shared with the console demo; no UI dependency -->
20+
<ItemGroup>
21+
<ProjectReference Include="..\EntglDb.Demo.Game\EntglDb.Demo.Game.csproj" />
22+
</ItemGroup>
23+
24+
<ItemGroup>
25+
<!-- MonoGame DesktopGL (cross-platform OpenGL window) -->
26+
<PackageReference Include="MonoGame.Framework.DesktopGL" Version="3.8.4.1" />
27+
<!-- Content pipeline tool — build-time only -->
28+
<PackageReference Include="MonoGame.Content.Builder.Task" Version="3.8.4.1" />
29+
30+
<!-- Microsoft.Extensions.* come transitively from the EntglDb.Demo.Game project reference -->
31+
</ItemGroup>
32+
33+
<!-- MonoGame content pipeline descriptor -->
34+
<ItemGroup>
35+
<MonoGameContentReference Include="Content\Content.mgcb" />
36+
</ItemGroup>
37+
38+
<!-- Copy appsettings from the shared demo project so peers config works -->
39+
<ItemGroup>
40+
<None Update="appsettings.json">
41+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
42+
</None>
43+
</ItemGroup>
44+
45+
</Project>
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using EntglDb.Core;
2+
using EntglDb.Core.Network;
3+
using EntglDb.Demo.Game;
4+
using EntglDb.Demo.Game.MonoGame;
5+
using EntglDb.Network;
6+
using EntglDb.Persistence.BLite;
7+
using Microsoft.Extensions.DependencyInjection;
8+
using Microsoft.Extensions.Hosting;
9+
using Microsoft.Extensions.Logging;
10+
11+
// Parse args: [nodeId] [tcpPort]
12+
string nodeId = args.Length > 0 ? args[0] : $"hero-{new Random().Next(1000, 9999)}";
13+
int tcpPort = args.Length > 1 ? int.Parse(args[1]) : new Random().Next(15000, 16000);
14+
15+
var dataPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data");
16+
Directory.CreateDirectory(dataPath);
17+
18+
var builder = Host.CreateApplicationBuilder(args);
19+
20+
// Host.CreateApplicationBuilder automatically adds appsettings.json
21+
builder.Logging.ClearProviders();
22+
builder.Logging.AddConsole();
23+
builder.Logging.SetMinimumLevel(LogLevel.Warning);
24+
25+
// Peer configuration
26+
var peerConfig = new StaticPeerNodeConfigurationProvider(
27+
new PeerNodeConfiguration
28+
{
29+
NodeId = nodeId,
30+
TcpPort = tcpPort,
31+
AuthToken = "DungeonCrawler-Secret"
32+
});
33+
34+
builder.Services.AddSingleton<IPeerNodeConfigurationProvider>(peerConfig);
35+
36+
// Database path per node
37+
var databasePath = Path.Combine(dataPath, $"{nodeId}.blite");
38+
39+
// Register EntglDb with BLite
40+
builder.Services.AddEntglDbCore()
41+
.AddEntglDbBLite<GameDbContext, GameDocumentStore>(sp => new GameDbContext(databasePath), databasePath + ".meta")
42+
.AddEntglDbNetwork<StaticPeerNodeConfigurationProvider>();
43+
44+
// Build DI container
45+
var host = builder.Build();
46+
47+
// Start background sync in a background thread; game loop runs on the main thread
48+
var cts = new CancellationTokenSource();
49+
var hostTask = host.StartAsync(cts.Token);
50+
51+
// Resolve dependencies
52+
var db = host.Services.GetRequiredService<GameDbContext>();
53+
var rng = new Random();
54+
55+
// GameEngine is a lightweight object, not a DI-registered service
56+
var engine = new GameEngine(db, nodeId, rng);
57+
58+
try
59+
{
60+
using var game = new DungeonGame(engine, nodeId);
61+
game.Run();
62+
}
63+
finally
64+
{
65+
cts.Cancel();
66+
await hostTask;
67+
await host.StopAsync();
68+
}

0 commit comments

Comments
 (0)