NosCore intentionally uses per-resource locks rather than a global lock manager. Each lock has one job and lives on the resource it protects. When you add a new lock, follow the existing pattern below — do not introduce a centralised LockManager.
| Resource | Lock | Where it lives |
|---|---|---|
| Inbound packets for a session | SemaphoreSlim _handlingPacketLock |
ClientSession |
| Damage application to a single entity | SemaphoreSlim HitSemaphore |
IAliveEntity (player / monster / NPC) |
| SignalR hub connect/disconnect | SemaphoreSlim _connectionLock |
BaseHubClient / PubSubHubClient |
- One purpose per lock. A lock guards exactly one mutable resource. Don't reuse an existing semaphore for unrelated state.
- Lock lives on the resource. Put it as a field on the entity / session / client it protects, not in a static dictionary.
- Use
SemaphoreSlim(1, 1)for serialization,ConcurrentDictionaryfor shared maps. Don't reach forlock(obj)— async code can'tawaitinside alock. - Always
try { ... } finally { _lock.Release(); }when acquiring a semaphore. Withoutfinallyan exception leaves the lock held. - Never hold a lock across an external I/O call (DB, HTTP, SignalR). If the call is slow, drop the lock first or use a more granular one.
- Don't acquire two locks at once unless the order is documented at both call sites. Lock-ordering bugs surface at scale.
If a new feature needs cross-cutting coordination (e.g. "all map instances must agree on X"), prefer publishing a Wolverine message to a handler that owns the state — not adding a lock that spans subsystems.
For "do X every N minutes" jobs, use the Wolverine pattern in NosCore.GameObject.Messaging.ScheduledJobs/:
- Define a message record:
public sealed record FooJobMessage; - Define a handler with a
Handle(FooJobMessage _)method. - Register a
RecurringMessagePublisher<FooJobMessage>as anIHostedServicein the relevant*Bootstrap.cs.
For one-shot delayed work, publish via IMessageBus.PublishAsync with a DeliveryOptions { ScheduledTime = ... } — see Wolverine docs for details.
Cross-cutting reactions ("on monster killed → award XP, update quest progress, write family log") should be Wolverine messages, not direct calls between services. See NosCore.GameObject.Messaging.Events/MonsterKilledEvent.cs for the template.
- Events live in
NosCore.GameObject.Messaging.Events/assealed record EventNameEvent(...). Use past tense —XHappenedEvent— to make it clear the event represents something that already occurred. - Handlers live in
NosCore.GameObject.Messaging.Handlers/<Domain>/, one folder per packet/feature area (e.g.Guri/,Nrun/,UseItem/,MapItem/,Map/). Class names always end withHandler, neverEventHandler. Handlers are plain classes — Wolverine discovers them by convention via theHandle/HandleAsyncmethod. - One handler per file. Multiple handlers can subscribe to the same event; each filters internally with an early
returnif the event isn't relevant. - Publishing: inject
Wolverine.IMessageBusand callmessageBus.PublishAsync(new XEvent(...)). Don't introduce per-domain "runner" services — the bus is the runner.
Recurring jobs (e.g. periodic save) are registered as RecurringMessagePublisher<TMessage> hosted services in the relevant *Bootstrap.cs. The publisher fires a fresh message every interval; the Wolverine handler does the work. See Messaging/ScheduledJobs/ for examples.
For one-shot delayed work (e.g. "expire this buff in 30s"), publish via IMessageBus.PublishAsync with DeliveryOptions { ScheduledTime = ... }.