feat(gameserver): binary world save instead of SQL (#23) #40

Merged
marco merged 5 commits from docs/persistence-study into main 2026-07-18 16:47:15 +02:00
Owner

Closes #23.

The game world is authoritative in memory; the DB was only ever a periodic snapshot target, never queried during play. This replaces the EF game layer (dual-provider migrations paid on every world-state change) with a single versioned binary save (world.sav):

  • WorldSaveFile — versioned format (magic ISOW + version), crash-safe atomic write (.tmpFile.Replace keeping .bak).
  • PlayerStore — whole save held in memory, loaded at startup; Load/Save update the map, FlushAsync writes to disk. Never touched in the tick loop.
  • Removed the entire EF game layer: GameDbContext (+ providers), migrations, persisted entities, EF/Npgsql/SQLite packages, Database/ConnectionStrings config, migrate-game recipe.
  • Format documented in docs/world-save-format.md.
  • Auth keeps its SQL store (moving Auth off the DB is a separate, later phase).

All tests green, 0 warnings, csharpier clean.

Definition of Done

  • world.sav is created next to the GameServer content root on first flush; a wrong magic or unknown version throws InvalidDataException (never a silent misread).
  • Player position, inventory and trained skills survive a full server restart (proven by PlayerStore_FlushedStatePersistsAcrossReload).
  • A crash mid-write leaves the previous good save intact (.bak retained; atomic replace).
  • No EF / Npgsql / SQLite dependency remains in IsoMmo.GameServer and no game DB migration folder exists.
  • docs/world-save-format.md documents the byte layout and the version-bump migration procedure.
Closes #23. The game world is authoritative in memory; the DB was only ever a periodic snapshot target, never queried during play. This replaces the EF game layer (dual-provider migrations paid on every world-state change) with a single versioned binary save (`world.sav`): - `WorldSaveFile` — versioned format (magic `ISOW` + version), crash-safe atomic write (`.tmp` → `File.Replace` keeping `.bak`). - `PlayerStore` — whole save held in memory, loaded at startup; Load/Save update the map, `FlushAsync` writes to disk. Never touched in the tick loop. - Removed the entire EF game layer: `GameDbContext` (+ providers), migrations, persisted entities, EF/Npgsql/SQLite packages, `Database`/`ConnectionStrings` config, `migrate-game` recipe. - Format documented in `docs/world-save-format.md`. - Auth keeps its SQL store (moving Auth off the DB is a separate, later phase). All tests green, 0 warnings, csharpier clean. ## Definition of Done - [ ] `world.sav` is created next to the GameServer content root on first flush; a wrong magic or unknown version throws `InvalidDataException` (never a silent misread). - [ ] Player position, inventory and trained skills survive a full server restart (proven by `PlayerStore_FlushedStatePersistsAcrossReload`). - [ ] A crash mid-write leaves the previous good save intact (`.bak` retained; atomic replace). - [ ] No EF / Npgsql / SQLite dependency remains in `IsoMmo.GameServer` and no game DB migration folder exists. - [ ] `docs/world-save-format.md` documents the byte layout and the version-bump migration procedure.
docs: persistence study — DB vs binary snapshots for world state (#23)
All checks were successful
ci / Lint & Test (pull_request) Successful in 1m41s
ecc13f066d
Grounded analysis: the World is already in-memory-first and the game DB is never
queried, so relational storage buys little and costs a dual-provider migration on
every world-state change. Three options (normalized SQL / ModernUO binary files /
blob-in-SQL), a comparison table, and a recommendation (blob-in-SQL as the
pragmatic middle; binary if "no DB for game state" is a hard goal; keep Auth on SQL).
feat(gameserver): persist world to a binary save file instead of SQL
Some checks failed
ci / Lint & Test (pull_request) Failing after 2m10s
cd88b62626
The game world is authoritative in memory and the DB was only ever a
periodic snapshot target, never queried during play. Replace the EF
game layer (dual-provider migrations paid on every world-state change)
with a single versioned binary save (world.sav): in-memory PlayerStore,
crash-safe atomic write, format documented in docs/world-save-format.md.
Auth keeps its SQL store.
marco changed title from docs: persistence study — DB vs binary snapshots (#23) to feat(gameserver): binary world save instead of SQL (#23) 2026-07-18 05:05:12 +02:00
test(gameserver): raise WebSocket receive timeout to 60s for busy CI runners (#43)
All checks were successful
ci / Lint & Test (pull_request) Successful in 1m33s
7f8d609b00
feat(gameserver): /save admin command to force a world snapshot (#41) (#42)
Some checks failed
ci / Lint & Test (pull_request) Failing after 2m28s
aad24a22b4
Closes #41. **Stacked on #40** — review/merge that first; this PR targets the persistence branch so its diff is only the `/save` work.

Adds an admin `/save` chat command to force an immediate world save instead of waiting for the 15s periodic snapshot.

- New shared `WorldPersister`: gathers a snapshot of every online player and flushes it to `world.sav`. Both the periodic `PersistenceService` and `/save` go through the **same single instance**, and a lock-free gate (`Interlocked`) skips a save requested while another is in flight — so a manual save and the periodic one never write the file at once.
- `PersistenceService` now delegates to `WorldPersister` (timer + shutdown).
- `SaveCommand : GmCommand` (`/save`), authorized server-side by the signed `admin` claim via `CanExecute`. It returns a `SaveWorld` result the session handler fulfils (persistence is off the `World` facade, like targeting), replying with a `SystemMessage`: saved N players / nothing to save / already in progress / failed.
- `docs/gm-commands.md` updated with the `/save` row.

Tests: `WorldPersisterTests` (writes file + reload / empty world / sequential saves both persist) and `CommandTests` (`/save` as admin returns `SaveWorld`; non-admin refused). Whole suite green (GameServer 110), 0 warnings, csharpier clean.

## Definition of Done
- [x] `/save` as admin writes `world.sav` immediately and reports how many players were saved.
- [x] Non-admin `/save` is refused and writes no file.
- [x] `/save` with no players online reports nothing to save and writes no file.
- [x] A concurrent save is skipped with an already-in-progress reply (single shared `WorldPersister`).
- [x] Periodic save and `/save` share one code path (`WorldPersister`).
- [x] `docs/gm-commands.md` lists `/save`.

Reviewed-on: #42
fix(gameserver): send Welcome before entering the area of interest
All checks were successful
ci / Lint & Test (pull_request) Successful in 1m46s
ee7a00e9cd
A connecting player was made visible to the tick's AoI reconcile (interest.Enter)
before its Welcome was sent, so under load a tick could broadcast ItemSpawned/
PlayerJoined to the connection ahead of its Welcome. A client (and the integration
test) that treats Welcome as the first message then drops that broadcast — and because
the reconcile marks entities as known, it is never re-sent, hanging the client. Send the
initial snapshot first, enter AoI second. Reverts the 20s->60s receive-timeout stopgap.
marco merged commit 69847236c7 into main 2026-07-18 16:47:15 +02:00
marco deleted branch docs/persistence-study 2026-07-18 16:47:15 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
marco/IsoMmo!40
No description provided.