Backend Server¶
Summary:
eternal-serveris an ASP.NET Core 8.0 REST API backed by PostgreSQL 16, running as a Docker Compose stack alongside a Blazor admin dashboard. It provides account management, character CRUD, full persistence (save/load covering items, equipment, glyphs, per-item polymorphic state modules, quests, compendium, and world map), and game session management with heartbeat-based lifecycle. The persistence layer uses delete-and-insert inside a single transaction — every save replaces all character data atomically. Variable-shape data (item modifiers, per-item state modules, localizable text) is stored as JSONB for direct queryability. Sessions are kept alive by client heartbeats and automatically reaped when stale. The UE5 client communicates with it viaUEternalApiSubsystem.
Table of Contents¶
- Architecture Overview
- Local Development
- Database Schema
- State Modules (JSONB Shape)
- API Endpoints
- Persistence Pipeline
- Response Format
- DTO ↔ UE5 Struct Mapping
- Source References
- Related Systems
- Recent Changes
Architecture Overview¶
UE5 Client (UEternalApiSubsystem)
|
| HTTP/JSON
v
+-----------------------------------+
| Eternal.Server.Api |
| (ASP.NET Core 8.0) |
| |
| Controllers |
| +-- AccountsController |
| +-- CharactersController |
| +-- PersistenceController |
| +-- SessionsController |
| |
| Repositories (Scoped DI) |
| +-- AccountRepository |
| +-- CharacterRepository |
| +-- PersistenceRepository |
| +-- GameSessionRepository |
| |
| SessionReaperService (hosted) |
+-----------------------------------+
|
| EF Core + Npgsql
v
+-----------------------------------+
| PostgreSQL 16 |
| Database: eternal |
| 12 tables, snake_case columns |
+-----------------------------------+
Project Structure¶
| Project | Purpose |
|---|---|
Eternal.Server.Api |
Host entry point (Program.cs), DI configuration, Swagger, reaper registration |
Eternal.Shared |
Controllers, repositories, DTOs, EternalDbContext, entity models |
Eternal.Backend.Core |
Logging infrastructure (NLog) |
Eternal.Core |
Shared plumbing: TaskResult<T>, HTTP helpers |
Eternal.Dashboard |
Separate Blazor Server project — log viewer, read-only DB inspection |
Eternal.Backend.Tests |
Test helpers, constants, mock serializer |
Eternal.Integration.Tests |
xUnit integration suite (Account, Character, Persistence, Sessions, JSON contracts) |
Key Design Decisions¶
| Decision | Rationale |
|---|---|
| EF Core + Npgsql for everything | Single DbContext (EternalDbContext) manages all entities; no raw ADO.NET |
EnsureCreatedAsync() instead of migrations |
Pre-prod policy — wipe + recreate schema when the model changes. No migration history to maintain. |
| Scoped DI for repositories | One DbContext per request, proper disposal |
| Delete-and-insert saves | Simpler than diffing — full snapshot replacement per save inside one transaction |
| JSONB for variable-shape data | Item modifiers, per-item state modules, compendium FText — shapes with arrays or polymorphism don't map cleanly to relational columns but are still queryable via JSONB operators |
| camelCase JSON output, case-insensitive input | [JsonPropertyName] attributes pin exact shapes for UE interop; deserializer accepts either case since UE ships PascalCase UPROPERTY names |
Local Development¶
Prerequisites¶
- .NET 8.0 SDK
- Docker Desktop (for the full stack)
Docker Compose Stack¶
The full backend runs as three Docker containers via docker-compose.yml:
| Service | Container | Port | Purpose |
|---|---|---|---|
| PostgreSQL 16 | eternal |
5432 | Database with persistent volume |
| API | eternal-api |
5065 | REST API, Swagger, session reaper |
| Dashboard | eternal-dashboard |
5066 | Blazor Server admin dashboard, reads API logs |
cd /projects/eternal-server
# Start the full stack (build + run)
docker compose up -d --build
# Rebuild after code changes
docker compose up -d --build
# View logs
docker compose logs -f api
All services use restart: always — they survive reboots, crashes, and local dotnet test runs (Docker isolates the running binaries from local builds).
Services connect to PostgreSQL via Docker DNS (Host=postgres instead of Host=localhost), configured through environment variable overrides. The appsettings.json files still use localhost for running outside Docker.
Database Initialization¶
On startup, Program.cs calls EnsureCreatedAsync() (in Development mode) which auto-creates tables from the EF Core model. No manual migrations needed. After adding new entities, drop the schema and restart:
docker exec -i eternal psql -U testuser -d eternal -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
docker compose restart api
Database Schema¶
Entity Relationship¶
accounts
|
| 1:N (CASCADE DELETE)
v
characters ──────────────────────────────────────┐
| |
| 1:N (CASCADE DELETE on all) |
v v
persistence_items inventory_slots equipment_slots
|
| 1:N
v
stone_plates
|
| 1:N
v
glyph_placements
characters ──> quest_progress (1:N)
characters ──> world_map_nodes (1:N)
characters ──> domains (1:N)
characters ──> compendium_entries (1:N)
accounts ──> game_sessions (1:N, optional — host_account_id nullable, SET NULL)
Tables¶
All persistence tables use composite primary keys keyed on character_id plus a
natural identifier. No synthetic surrogate id columns. This matches how the data is
loaded (every query filters by character_id) and keeps CASCADE deletes cheap.
accounts¶
| Column | Type | Notes |
|---|---|---|
account_id |
uuid |
PK |
username |
text |
|
email |
text |
|
password |
text |
BCrypt hash via PasswordHasher<Account> |
created |
timestamptz |
NOT NULL |
updated |
timestamptz |
characters¶
| Column | Type | Notes |
|---|---|---|
character_id |
uuid |
PK |
account_id |
uuid |
FK → accounts (CASCADE) |
player_class_template_id |
text |
Maps to UE5 class template ID |
name |
text |
Display name |
crafting_resource |
integer |
Currency |
play_time |
real |
Seconds |
save_version |
integer |
Client-authored save schema version |
save_timestamp |
timestamptz |
Last save wall-clock time |
current_node_id |
text |
UE5 FName — player's last world-map position |
created |
timestamptz |
|
updated |
timestamptz |
persistence_items¶
| Column | Type | Notes |
|---|---|---|
instance_id |
uuid |
PK — UE5 FGuid, stable identity across save/load |
character_id |
uuid |
FK → characters (CASCADE) |
item_id |
text |
Template reference (e.g. Weapon_Sword_Flame) resolved by UE5 UItemRegistrySubsystem |
stack_count |
integer |
Stack size |
item_level |
integer |
Rolled item level |
consumable_uses_left |
integer |
Remaining usages (0 for non-consumables) |
modifiers |
jsonb |
Full FEquipmentModifiers object: {Implicits, Prefixes, Suffixes, ScalingModifiers} — consolidated into one column |
state_modules |
jsonb |
Polymorphic per-instance state module wrappers — see State Modules |
created_at |
timestamptz |
|
updated_at |
timestamptz |
inventory_slots¶
| Column | Type | Notes |
|---|---|---|
character_id |
uuid |
PK part 1, FK → characters (CASCADE) |
item_instance_id |
uuid |
PK part 2, FK → persistence_items (CASCADE) |
top_left_index |
integer |
Top-left grid cell of the item's placement |
equipment_slots¶
| Column | Type | Notes |
|---|---|---|
character_id |
uuid |
PK part 1, FK → characters (CASCADE) |
slot_tag |
text |
PK part 2 — gameplay tag (e.g. Equipment.Slot.RightHand) |
item_instance_id |
uuid |
FK → persistence_items (CASCADE) |
stone_plates¶
| Column | Type | Notes |
|---|---|---|
character_id |
uuid |
PK part 1, FK → characters (CASCADE) |
stone_plate_instance_id |
uuid |
PK part 2 — the stone plate's item instance id |
slot_index |
integer |
Which of the character's stone-plate wall slots holds this plate |
Stone plate rows carry the wall-slot assignment only. The stone plate item itself
(name, icon, sockets) lives in persistence_items with the matching instance_id.
glyph_placements¶
| Column | Type | Notes |
|---|---|---|
character_id |
uuid |
PK part 1, FK → characters (CASCADE) |
stone_plate_instance_id |
uuid |
PK part 2 — the plate this glyph sits on |
socket_index |
integer |
PK part 3 — socket position within the plate |
glyph_item_instance_id |
uuid |
The glyph item occupying that socket |
Glyph placements have no direct FK to stone_plates — the join is logical via
(character_id, stone_plate_instance_id). Both tables cascade off characters.
quest_progress¶
| Column | Type | Notes |
|---|---|---|
character_id |
uuid |
PK part 1, FK → characters (CASCADE) |
quest_tag |
text |
PK part 2 — quest gameplay tag |
status |
text |
Active, Completed, or Known |
UE5 stores quest state as three FGameplayTagContainer fields (QuestTags,
ActiveQuests, CompletedQuests). The server flattens these into individual rows:
tags in CompletedQuests get status Completed, tags in ActiveQuests get Active,
tags known but neither active nor completed get Known. LoadQuestProgress inverts
the mapping on read.
world_map_nodes¶
| Column | Type | Notes |
|---|---|---|
character_id |
uuid |
PK part 1, FK → characters (CASCADE) |
node_id |
text |
PK part 2 — node identifier (UE5 FName) |
state |
integer |
UE5 EWorldNodeState as uint8 |
is_visited |
boolean |
Maps to UE5 FWorldNodeSaveState.bVisited |
clear_count |
integer |
Number of times cleared |
domains¶
| Column | Type | Notes |
|---|---|---|
character_id |
uuid |
PK part 1, FK → characters (CASCADE) |
domain_tag |
text |
PK part 2 — domain gameplay tag |
is_liberated |
boolean |
Default false |
compendium_entries¶
| Column | Type | Notes |
|---|---|---|
character_id |
uuid |
PK part 1, FK → characters (CASCADE) |
entry_id |
text |
PK part 2 — compendium entry identifier |
title |
jsonb |
FText as JSON pass-through (UE5 ships multiple FText export formats — keep verbatim) |
content |
jsonb |
FText as JSON pass-through |
related_quest_tag |
text |
Associated quest tag (empty string if none) |
required_tag |
text |
Prerequisite tag (empty string if none) |
discovery_time |
timestamptz |
When the entry was discovered (nullable) |
has_been_read |
boolean |
Whether the player has viewed the entry |
game_sessions¶
| Column | Type | Notes |
|---|---|---|
session_id |
uuid |
PK |
host_account_id |
uuid |
FK → accounts (SET NULL), nullable |
host_address |
text |
Host IP address |
port |
integer |
Host port |
status |
text |
Open, Full, Closed |
max_players |
integer |
Default 4 |
current_players |
integer |
Default 1 |
session_name |
text |
Display name |
created_at |
timestamptz |
|
updated_at |
timestamptz |
|
last_heartbeat_at |
timestamptz |
Updated by heartbeat endpoint, used by reaper |
Indexes on status (for listing open sessions) and host_account_id.
State Modules (JSONB Shape)¶
persistence_items.state_modules stores UE5's UItemObject::StateModules — the
polymorphic per-instance runtime state pattern. The column is a JSONB array of
type-tagged wrapper entries; the server treats the inner Data as opaque and
passes it through as a JsonElement.
Casing convention:
| Layer | Case | Why |
|---|---|---|
Outer wrapper (StructName, Data) |
PascalCase | The server DTO uses [JsonPropertyName] to force the UE UPROPERTY names on re-serialize, so @> containment queries match UE-style keys exactly |
Inner Data fields (module's UPROPERTY contents) |
lowerCamelCase | UE's FJsonObjectConverter lowercases the first character of every UPROPERTY name via StandardizeCase; server passes the object through verbatim |
Example stored shape for a FRemnantItemState module:
[
{
"StructName": "RemnantItemState",
"Data": {
"schemaVersion": 1,
"state": "Awakened",
"echoMods": [{ "rolledModifier": {...}, "bIsRevealed": true, "bIsActive": true }],
"activeDesire": {"taskTag": {"tagName": "Desire.Kill.Any"}, "progress": 4, "target": 4},
"era": {"tagName": "Era.Debug"},
"poolRef": "/Game/DataAssets/Items/Modifiers/RemnantItems.RemnantItems"
}
}
]
JSONB queries for trade validation, analytics, admin tooling, and batch migrations work out of the box:
-- All characters holding an awakened Remnant
SELECT character_id, instance_id FROM persistence_items
WHERE state_modules @> '[{"StructName":"RemnantItemState","Data":{"state":"Awakened"}}]'::jsonb;
-- Add a new field to every v1 RemnantItemState row with a default
UPDATE persistence_items
SET state_modules = jsonb_set(state_modules, '{0,Data,postAwakenBonus}', '0')
WHERE state_modules @> '[{"StructName":"RemnantItemState","Data":{"schemaVersion":1}}]'::jsonb;
Indexing: no index on state_modules today. A GIN index (USING gin (state_modules jsonb_path_ops)) lands when trade-validation load justifies it.
Typed access: opt-in and deferred. The backend keeps Data as generic
JsonElement? until a specific capability (trade validation, analytics projection)
needs a typed DTO for one module type. Other module types stay generic.
See Item State Modules for the UE-side pattern, lifecycle, replication, and serializer.
API Endpoints¶
Controllers use the exact route prefix declared in [Route(...)] — no /api
prefix. All endpoints return TaskResult<T> (see Response Format).
Accounts (/accounts)¶
| Method | Route | Request Body | Response | Notes |
|---|---|---|---|---|
| POST | /accounts |
CreateAccountRequest |
Account |
BCrypt password hashing via PasswordHasher<Account> |
| POST | /accounts/login |
LoginRequest |
Account |
Password verification |
| GET | /accounts/{id:guid} |
— | Account |
|
| DELETE | /accounts/{id:guid} |
— | — | Cascades to characters, items, sessions |
Characters (/characters)¶
| Method | Route | Request Body | Response | Notes |
|---|---|---|---|---|
| POST | /characters |
CreateCharacterRequest |
Character |
|
| GET | /characters/{characterId:guid} |
— | Character |
|
| GET | /characters/account/{accountId:guid} |
— | Character[] |
All characters for account |
| PUT | /characters/{characterId:guid} |
UpdateCharacterRequest |
Character |
|
| DELETE | /characters/{characterId:guid} |
— | — | Cascades to all persistence tables |
Persistence (/characters/{characterId:guid})¶
| Method | Route | Request Body | Response | Notes |
|---|---|---|---|---|
| POST | /characters/{characterId:guid}/save |
SaveCharacterRequest |
— | Full snapshot save |
| GET | /characters/{characterId:guid}/load |
— | LoadCharacterResponse |
Full snapshot load |
The {characterId} in the route must match the character ID in the request body — the controller validates this and returns 400 Bad Request on mismatch.
Sessions (/sessions)¶
| Method | Route | Request Body | Response | Notes |
|---|---|---|---|---|
| POST | /sessions |
CreateSessionRequest |
GameSession |
HostAccountId of Guid.Empty → null |
| GET | /sessions |
— | GameSession[] |
Open sessions only, newest first |
| GET | /sessions/{id:guid} |
— | GameSession |
|
| PUT | /sessions/{id:guid} |
UpdateSessionRequest |
GameSession |
Partial update (status, player count) |
| PUT | /sessions/{id:guid}/heartbeat |
— | — | Updates last_heartbeat_at |
| DELETE | /sessions/{id:guid} |
— | — |
Sessions are short-lived — they exist while a game is active and are deleted when the host shuts down. USessionComponent on UE5's GameState handles registration and cleanup automatically.
Session Lifecycle & Heartbeat¶
Sessions follow a heartbeat-based lifecycle to handle crashes and ungraceful disconnects:
Host creates session (POST /sessions)
|
v
USessionComponent starts 30s heartbeat timer
|
+---> PUT /sessions/{id}/heartbeat (every 30s)
| Server updates last_heartbeat_at timestamp
|
v (on graceful shutdown)
USessionComponent::EndPlay() → DELETE /sessions/{id}
v (on crash / ungraceful disconnect)
SessionReaperService (server-side background service)
|
+-- Sweeps every 60s
+-- Deletes sessions where last_heartbeat_at > 2 minutes stale
This ensures the session list stays clean even when hosts crash, tests leak sessions, or network drops occur. The reaper runs as an ASP.NET Core BackgroundService using IServiceScopeFactory for scoped DbContext access.
Persistence Pipeline¶
Save Flow¶
UE5: UPersistenceComponent::RequestSave()
|
v
UE5: UPersistenceApiService::SaveCharacter(CharacterId, FCharacterSaveData)
|
| POST /characters/{id}/save
v
Server: PersistenceController.Save()
|
| Validate route characterId == body characterId
v
Server: PersistenceRepository.SaveCharacterAsync()
|
| BEGIN TRANSACTION
|
| 1. Upsert character row (player class, currency, play time, save_version,
| save_timestamp, current_node_id)
| 2. ExecuteDeleteAsync() on every child table for this character:
| glyph_placements, stone_plates, equipment_slots, inventory_slots,
| persistence_items, quest_progress, compendium_entries,
| world_map_nodes, domains
| 3. INSERT persistence_items (modifiers JSONB + state_modules JSONB)
| 4. INSERT inventory_slots
| 5. INSERT equipment_slots (tag name extracted from GameplayTagDto)
| 6. INSERT stone_plates + glyph_placements (nested from DTO)
| 7. INSERT quest_progress (flatten 3 tag containers → Active/Completed/Known rows)
| 8. INSERT compendium_entries
| 9. INSERT world_map_nodes
|10. INSERT domains
|
| COMMIT (rollback on any exception)
v
Server: Return TaskResult<Empty>
Load Flow¶
UE5: UPersistenceComponent::RequestLoad()
|
v
UE5: UPersistenceApiService::LoadCharacter(CharacterId)
|
| GET /characters/{id}/load
v
Server: PersistenceController.Load()
|
v
Server: PersistenceRepository.LoadCharacterAsync()
|
| Sequential AsNoTracking queries (EF Core default):
| 1. character row (null → return null response)
| 2. persistence_items
| 3. inventory_slots
| 4. equipment_slots
| 5. stone_plates
| 6. glyph_placements
| 7. quest_progress
| 8. compendium_entries
| 9. world_map_nodes
| 10. domains
|
| Assemble SaveCharacterRequest DTO
| - Deserialize modifiers JSONB → JsonElement pass-through
| - Deserialize state_modules JSONB → List<ItemStateModuleEntryDto>
| - Group glyph_placements by stone_plate_instance_id, attach to plates
| - Reconstruct 3 quest tag containers from status column
| - Wrap slot_tag / domain_tag / related_quest_tag back into GameplayTagDto
v
Server: Return TaskResult<LoadCharacterResponse>
|
v
UE5: Deserialize → FCharacterSaveData → apply to live game objects
Why Delete-and-Insert?¶
The save strategy deletes all existing data for a character and re-inserts everything fresh. This is simpler than computing diffs:
- No need to track which items were added, removed, or modified
- No stale data from items that were consumed or dropped
- CASCADE DELETE on
character_idhandles all child tables in one statement - Entire operation is atomic within a single transaction
- Trade-off: more I/O per save, but character data sets are small (dozens of items, not thousands)
Response Format¶
All endpoints return TaskResult<T>:
| Field | Type | Notes |
|---|---|---|
content |
T or null |
Response payload |
isSuccessful |
bool |
Success indicator |
message |
string |
Error details (empty on success) |
UE5 maps this to FApiResponse:
TaskResult field |
FApiResponse field |
|---|---|
isSuccessful |
bSuccess (combined with HTTP status) |
content |
Content (TSharedPtr<FJsonObject>) |
message |
ErrorMessage |
DTO ↔ UE5 Struct Mapping¶
Persistence DTOs¶
| Server DTO | UE5 Struct | Notes |
|---|---|---|
SaveCharacterRequest |
FCharacterSaveData |
Top-level save payload |
LoadCharacterResponse |
FCharacterSaveData |
Wraps save data for load response |
ItemSaveDto |
FItemSaveState |
Per-item data + modifiers |
InventorySlotDto |
FInventorySlotSaveState |
Slot index → item mapping |
EquipmentSlotDto |
FEquipmentSlotSaveState |
Tag → item mapping |
StonePlateDto |
FStonePlateSaveState |
Plate sockets on items |
GlyphPlacementDto |
FGlyphPlacementSaveState |
Glyphs within plates |
QuestSaveStateDto |
Three FGameplayTagContainer fields |
Flattened with status discriminator |
CompendiumEntryDto |
FCompendiumEntry |
Lore/knowledge entries with FText fields |
WorldMapProgressDto |
FWorldMapSaveState |
Nodes + domains + current node id |
WorldMapNodeDto |
FWorldNodeSaveState |
Per-node state (int), visited, clear count |
DomainSaveDto |
FDomainSaveState |
Per-domain liberation state |
ItemStateModuleEntryDto |
FItemStateModuleSaveEntry |
Polymorphic state module wrapper (see State Modules) |
GameplayTagDto |
FGameplayTag |
Tag name string |
GameplayTagContainerDto |
FGameplayTagContainer |
Array of tag names |
Account/Character DTOs¶
| Server DTO | UE5 Struct |
|---|---|
CreateAccountRequest |
FCreateAccountRequest |
LoginRequest |
FLoginRequest |
AccountResponse |
FAccountResponse |
CreateCharacterRequest |
FCreateCharacterApiRequest |
CharacterResponse |
FCharacterApiResponse |
Session DTOs¶
| Server DTO | UE5 Struct |
|---|---|
CreateSessionRequest |
FCreateSessionRequest |
UpdateSessionRequest |
FUpdateSessionRequest |
GameSession |
FSessionResponse |
JSONB Modifier Format¶
persistence_items.modifiers stores the full FEquipmentModifiers object in a single
JSONB column — four named arrays of FRolledModifier entries:
{
"implicits": [ { "modifierDefinitionId": "crit_chance", "rolledValue": 12.5, "modifierTier": "Tier2", "source": "Drop" } ],
"prefixes": [ { "modifierDefinitionId": "increased_fire_damage", "rolledValue": 30.0, "modifierTier": "Tier3", "source": "Drop" } ],
"suffixes": [ ],
"scalingModifiers": [ ]
}
The DTO (ItemSaveDto.Modifiers) is JsonElement? for pass-through — the server
doesn't parse or validate the inner shape, it just round-trips whatever UE sends.
Keys are UE-default lowerCamelCase.
source is ECraftingModifierSource as a string: Drop (loot roll), Crafted
(crafting station), or Echo (Remnant state-module mod). The apply-time gate on
the client reads this field to filter which modifiers spawn gameplay effects (Echo
mods apply only when the owning state module flags them active).
Source References¶
All server source lives in a separate repository (eternal-server).
| Topic | File |
|---|---|
| DI + pipeline config | Eternal.Server.Api/Program.cs |
| DbContext (single consolidated) | Eternal.Shared/Database/EternalDbContext.cs |
| Entity models | Eternal.Shared/Models/*.cs |
| All persistence DTOs | Eternal.Shared/Dtos/PersistenceDtos.cs |
| Account endpoints | Eternal.Shared/Controllers/AccountsController.cs |
| Character endpoints | Eternal.Shared/Controllers/CharactersController.cs |
| Persistence endpoints | Eternal.Shared/Controllers/PersistenceController.cs |
| Save/load transaction logic | Eternal.Shared/Repositories/PersistenceRepository.cs |
| State-module serialize/deserialize helpers | Eternal.Shared/Repositories/PersistenceRepository.cs → SerializeStateModules / DeserializeStateModules |
| Session endpoints | Eternal.Shared/Controllers/SessionsController.cs |
| Session repository | Eternal.Shared/Repositories/GameSessionRepository.cs |
| Session entity | Eternal.Shared/Models/GameSession.cs |
| Session reaper background service | Eternal.Server.Api/Services/SessionReaperService.cs |
| Generic response wrapper | Eternal.Core/TaskResult.cs |
| Integration test suite | Eternal.Integration.Tests/*.cs |
| Docker Compose (API + Dashboard + Postgres) | docker-compose.yml |
Related Systems¶
- API Layer — UE5 client-side HTTP communication
- Replication Overview — Network state sync
- Item System — Items that get persisted
- Item State Modules — Polymorphic per-instance runtime state pattern;
state_modulesJSONB column's source of truth - Remnant Item System — First consumer of
state_modules - Quest System — Quest state that gets persisted
Recent Changes¶
| Date | Change | Reason |
|---|---|---|
| 2026-04-21 | Doc refresh against live schema: consolidated modifiers column, composite PKs, current route prefixes (no /api), updated DTO/modifier shape |
Doc had drifted since initial 2026-03 write; aligned with schema as of eternal-server master HEAD |
| 2026-04-21 | state_modules JSONB column + ItemStateModuleEntryDto on ItemSaveDto |
Remote-persist UE's UItemObject::StateModules; first consumer is Remnant FRemnantItemState. Wrapper shape + outer-PascalCase / inner-camelCase convention documented. |
| 2026-03-06 | Dockerized full stack (API + Dashboard + PostgreSQL), session heartbeat/reaper, compendium persistence, Account→Character CASCADE FK | Always-on infrastructure, stale session cleanup, lore persistence, referential integrity |
| 2026-03-03 | Session management: game_sessions table, SessionsController, GameSessionRepository |
"Game Room" multiplayer model — session registry for listen-server sessions |
| 2026-03 | Initial documentation | Document eternal-server architecture alongside persistence layer work |