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
15 changes: 15 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@ Include in every request:
- keep active-room indexing and keepalive behavior in `RoomDirectoryGrain`
- treat `[KeepAlive]` as explicit infrastructure-only usage

## Orleans grain constraints
- Never use bare `catch { }` — always `catch (Exception ex)` with `ILogger<T>`.
- Parallelize independent grain calls with `Task.WhenAll`, not sequential `await` in loops.
- Hoist repeated grain calls out of loops.
- Batch DB deletes with `WHERE ... IN (...)`, not per-entity `ExecuteDeleteAsync`.
- Use timer-flush for housekeeping writes (see `RoomPersistenceGrain`).
- No hardcoded limits in grains — pass from handlers via `IConfiguration`.
- Use tracked EF deletes when atomicity with inserts is required.
- Replace `.Ignore()` with a `LogAndForget` helper that logs faulted tasks.
- Cap in-memory per-event collections (message history, queues).
- One grain per responsibility — isolate heavy I/O into secondary grains (e.g. `RoomPersistenceGrain`).
- Use grain single-threading for concurrency safety (per-player `PurchaseGrain`, per-item `LimitedItemGrain`). No manual locks.
- Grains orchestrate their own outbound communication via `PlayerPresenceGrain.SendComposerAsync`. Callers do not send composers.
- All mutations to grain-owned data go through grain methods. No direct DB updates for grain-owned state.

## Task routing hints
- Handler task: use neighboring handler + `Turbo.Primitives/Orleans/GrainFactoryExtensions.cs`.
- Grain task: use grain interface + snapshot/state types as primary references.
Expand Down
68 changes: 67 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Activate the relevant skill checklist before editing code in that domain:
- `grain-development`
- Trigger: editing files under `Turbo.*\\Grains\\**` or `Turbo.Primitives/**/Grains/*.cs`.
- Enforce: keep ownership boundaries, lifecycle rules, and snapshot/state coherence.
- Enforce: all rules in the **Orleans grain development rules** section below.
- `session-presence-routing`
- Trigger: touching session gateway, presence flow, room routing, outbound composer fan-out.
- Enforce: player outbound via `PlayerPresenceGrain.SendComposerAsync`; no direct handler socket sends.
Expand Down Expand Up @@ -64,13 +65,78 @@ Default output format:
- Prefer deterministic handlers/services with clear guard clauses.
- Preserve cancellation and async flow where it already exists.
- Handle failure paths explicitly; do not ship happy-path-only changes.
- Avoid dead code, unused allocations, and broad catch blocks that hide errors.
- Avoid dead code, unused allocations, and broad catch blocks that hide errors (see **Orleans grain development rules** for specifics).
- For revision compatibility work, prefer restoring/adding missing incoming message contracts in `Turbo.Primitives/Messages/Incoming/**` before mutating serializer/composer payload behavior.
- Do not alter serializer/composer behavior by replacing real payload writes with placeholder constants (for example, unconditional `WriteInteger(0)`) unless explicitly requested.
- If work references `Revision<id>` parsers/serializers, edit the plugin repo path:
- `../turbo-sample-plugin/TurboSamplePlugin/Revision/**`
- Do not hallucinate those trees into `turbo-cloud`.

## Orleans grain development rules
These rules exist because every one of these mistakes has shipped and caused real issues.

### Never swallow exceptions silently
Every bare `catch { }` hides a real bug path. Always use `catch (Exception ex)` and log it.
If a cross-grain notification fails silently, state goes asymmetric and nobody knows why.
- **Required**: inject `ILogger<T>` into every grain that does cross-grain calls or DB work.
- **Forbidden**: bare `catch { }`, `catch (Exception) { }` without logging.

### Parallelize independent grain calls
When checking status on N grains (e.g. online status for a friend list), do not `await` each one in a `foreach`.
Grain calls to different grains can run concurrently with `Task.WhenAll`.
- Sequential = O(n) round-trips. Parallel = O(1) wall time.
- Apply everywhere: activation hydration, search results, batch accept/deny.

### Do not repeat identical grain calls in loops
If a grain method calls its own player's `GetSummaryAsync` inside a loop, hoist the call before the loop.
Same result every iteration = wasted round-trips.

### Batch DB operations
Do not loop `ExecuteDeleteAsync` per entity. Use a single `WHERE ... IN (...)` query.
Same for composer fan-out: collect all updates, send once.

### Use timer-based flush for housekeeping writes
Follow the `RoomPersistenceGrain` pattern: queue dirty state, flush with `RegisterGrainTimer` on interval, and flush on `OnDeactivateAsync`.
Do not issue per-event DB writes that block the grain turn.

### Do not hardcode limits in grains
Handlers already read configuration values (e.g. `Turbo:FriendList:UserFriendLimit`) from `IConfiguration` and pass them to grains.
Magic numbers like `Take(50)`, `Take(20)`, or `maxIgnoreCapacity = 100` must come from configuration parameters on the grain interface method.
A 10,000 user hotel needs different tuning than a 10 player dev server.

### Use tracked deletes for atomicity
`ExecuteDeleteAsync` commits immediately and bypasses the EF change tracker.
If a delete + insert must succeed or fail together, use `FirstOrDefaultAsync` + `Remove` so both go through one `SaveChangesAsync`.

### Replace .Ignore() with a LogAndForget helper
Orleans `.Ignore()` makes cross-grain failures invisible. Use a `LogAndForget` extension that calls `ContinueWith(OnlyOnFaulted)` to log the exception.
Still fire-and-forget, but failures are visible in production logs.

### Bound session/history collections
Any in-memory collection that grows per-message (e.g. conversation history) must have a configurable cap.
Without a cap, long-running sessions leak memory.

### One grain per responsibility — isolate heavy I/O
Each major domain component should operate in its own grain. When a grain needs heavy I/O (DB writes, persistence flushes), delegate that work to a dedicated secondary grain so it does not block the primary grain's turn.
- Example: `RoomGrain` delegates furniture saves to `RoomPersistenceGrain`. The room grain stays responsive while persistence queues and flushes.
- Do not combine domain logic and persistence flushing in the same grain.

### Use grain boundaries for thread safety
Orleans grains are single-threaded by design. Use this for concurrency-sensitive operations by giving each user their own grain for the operation.
- Example: each player gets a `PurchaseGrain` so catalog purchases are serialized per-player with no locks needed.
- Example: limited-edition items should use a dedicated grain (e.g. `LimitedItemGrain`) so concurrent buyers are safely serialized.
- Do not add manual locking (`lock`, `SemaphoreSlim`) inside grains — that fights the actor model.

### Grains orchestrate their own outbound communication
When grain state changes (e.g. wallet balance updates), the grain itself sends the snapshot to `PlayerPresenceGrain.SendComposerAsync`. The caller that triggered the change does not pass or send the composer — the grain owns that responsibility.
- **Correct**: handler calls `grain.UpdateWalletAsync(...)` → grain updates state → grain calls `PlayerPresenceGrain.SendComposerAsync(...)`.
- **Wrong**: handler calls `grain.UpdateWalletAsync(...)` → handler builds composer → handler sends composer to player.

### Do not mutate the database directly for grain-owned state
Grains may hold cached or in-memory state that will not reflect direct DB changes. All mutations to grain-owned data must go through the grain's methods, even when the player is offline.
- If a grain uses `[PersistentState]`, state is hydrated from the configured store (not DB) on activation. Direct DB edits will be overwritten by stale store data.
- Admin tools and external systems must call grain methods, not issue raw SQL/DB updates, for data that grains own.

## Profile and grain flow constraints
- Keep packet handlers orchestration-only:
- validate input
Expand Down
15 changes: 15 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,21 @@
- Lifecycle:
- inactive grains are Orleans-managed and can deactivate automatically unless explicitly marked `[KeepAlive]`.

## Grain runtime patterns
- Every grain that does cross-grain calls or DB work must inject `ILogger<T>` and log caught exceptions. No bare `catch { }`.
- Independent grain calls (e.g. checking online status for N friends) must use `Task.WhenAll`, not sequential `await` in a loop.
- Identical grain calls must not repeat inside loops — hoist before the loop.
- DB batch operations use single `WHERE ... IN (...)` queries, not per-entity `ExecuteDeleteAsync` loops.
- Housekeeping writes (e.g. delivered flags) follow the timer-flush pattern: queue dirty state, flush with `RegisterGrainTimer`, flush on `OnDeactivateAsync`. See `RoomPersistenceGrain` for reference.
- Do not hardcode limits (`Take(N)`, capacity constants) in grains. Pass them from handlers via `IConfiguration`.
- When a delete + insert must be atomic, use EF tracked operations (`Remove` + `SaveChangesAsync`), not `ExecuteDeleteAsync`.
- Replace `.Ignore()` on grain tasks with a `LogAndForget` helper that logs faulted continuations.
- In-memory collections that grow per-event (message history, queues) must have a configurable cap.
- One grain per responsibility: isolate heavy I/O (DB writes, persistence) into secondary grains so the primary domain grain stays responsive (e.g. `RoomGrain` → `RoomPersistenceGrain`).
- Use grain single-threading for concurrency safety: per-player `PurchaseGrain` for catalog buys, dedicated grains for limited-edition items. Do not add manual locks inside grains.
- Grains orchestrate their own outbound: when state changes, the grain sends the composer via `PlayerPresenceGrain.SendComposerAsync`. Callers do not build or send composers after calling a grain method.
- All mutations to grain-owned data must go through grain methods. Do not update the database directly — grains may hold cached state that will not reflect raw DB changes.

## Placement rules
- New host startup/wiring behavior:
- `Turbo.Main/` (usually `Program.cs`, `Extensions/`, or `Console/`)
Expand Down
13 changes: 13 additions & 0 deletions Turbo.Database/Context/TurboDbContext.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Turbo.Database.Entities.Catalog;
using Turbo.Database.Entities.Furniture;
using Turbo.Database.Entities.Messenger;
using Turbo.Database.Entities.Navigator;
using Turbo.Database.Entities.Players;
using Turbo.Database.Entities.Room;
Expand Down Expand Up @@ -58,6 +59,18 @@ public class TurboDbContext(DbContextOptions<TurboDbContext> options)

public DbSet<PlayerFavoriteRoomsEntity> PlayerFavouriteRooms { get; init; }

public DbSet<MessengerFriendEntity> MessengerFriends { get; init; }

public DbSet<MessengerRequestEntity> MessengerRequests { get; init; }

public DbSet<MessengerCategoryEntity> MessengerCategories { get; init; }

public DbSet<MessengerMessageEntity> MessengerMessages { get; init; }

public DbSet<MessengerBlockedEntity> MessengerBlocked { get; init; }

public DbSet<MessengerIgnoredEntity> MessengerIgnored { get; init; }

protected override void OnModelCreating(ModelBuilder mb)
{
base.OnModelCreating(mb);
Expand Down
22 changes: 22 additions & 0 deletions Turbo.Database/Entities/Messenger/MessengerBlockedEntity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Turbo.Database.Entities.Players;

namespace Turbo.Database.Entities.Messenger;

[Table("messenger_blocked")]
[Index(nameof(PlayerEntityId), nameof(BlockedPlayerEntityId), IsUnique = true)]
public class MessengerBlockedEntity : TurboEntity
{
[Column("player_id")]
public required int PlayerEntityId { get; set; }

[Column("blocked_player_id")]
public required int BlockedPlayerEntityId { get; set; }

[ForeignKey(nameof(PlayerEntityId))]
public required PlayerEntity PlayerEntity { get; set; }

[ForeignKey(nameof(BlockedPlayerEntityId))]
public required PlayerEntity BlockedPlayerEntity { get; set; }
}
22 changes: 22 additions & 0 deletions Turbo.Database/Entities/Messenger/MessengerIgnoredEntity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Turbo.Database.Entities.Players;

namespace Turbo.Database.Entities.Messenger;

[Table("messenger_ignored")]
[Index(nameof(PlayerEntityId), nameof(IgnoredPlayerEntityId), IsUnique = true)]
public class MessengerIgnoredEntity : TurboEntity
{
[Column("player_id")]
public required int PlayerEntityId { get; set; }

[Column("ignored_player_id")]
public required int IgnoredPlayerEntityId { get; set; }

[ForeignKey(nameof(PlayerEntityId))]
public required PlayerEntity PlayerEntity { get; set; }

[ForeignKey(nameof(IgnoredPlayerEntityId))]
public required PlayerEntity IgnoredPlayerEntity { get; set; }
}
33 changes: 33 additions & 0 deletions Turbo.Database/Entities/Messenger/MessengerMessageEntity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Turbo.Database.Entities.Players;

namespace Turbo.Database.Entities.Messenger;

[Table("messenger_messages")]
[Index(nameof(SenderPlayerEntityId), nameof(ReceiverPlayerEntityId), nameof(Timestamp))]
[Index(nameof(ReceiverPlayerEntityId), nameof(SenderPlayerEntityId), nameof(Timestamp))]
public class MessengerMessageEntity : TurboEntity
{
[Column("sender_id")]
public required int SenderPlayerEntityId { get; set; }

[Column("receiver_id")]
public required int ReceiverPlayerEntityId { get; set; }

[Column("message")]
public required string Message { get; set; }

[Column("timestamp")]
public required DateTime Timestamp { get; set; }

[Column("delivered")]
public bool Delivered { get; set; }

[ForeignKey(nameof(SenderPlayerEntityId))]
public required PlayerEntity SenderPlayerEntity { get; set; }

[ForeignKey(nameof(ReceiverPlayerEntityId))]
public required PlayerEntity ReceiverPlayerEntity { get; set; }
}
12 changes: 12 additions & 0 deletions Turbo.Database/Entities/Players/PlayerEntity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,16 @@ public class PlayerEntity : TurboEntity

[InverseProperty("PlayerEntity")]
public List<RoomChatlogEntity>? RoomChatlogs { get; set; }

[InverseProperty("PlayerEntity")]
public List<MessengerBlockedEntity>? MessengerBlocked { get; set; }

[InverseProperty("PlayerEntity")]
public List<MessengerIgnoredEntity>? MessengerIgnored { get; set; }

[InverseProperty("SenderPlayerEntity")]
public List<MessengerMessageEntity>? MessengerMessagesSent { get; set; }

[InverseProperty("ReceiverPlayerEntity")]
public List<MessengerMessageEntity>? MessengerMessagesReceived { get; set; }
}
Loading