Dispatches (Server Reports)
Server-generated reports delivered to accounts - a battle report, a scouting report, a trade receipt, a match summary. The game calls Create with recipients, a type and category key, title values, related ids and an opaque payload; each recipient gets their own copy. Players list them newest first (by folder, category, unread), open one (which fires Opened), mark read or unread, file them in the game's folders, keep them from expiring, delete (to a deleted folder, then for good) and forward a copy that names the forwarder - who may be forwarded to is the game's IDispatchPolicy. Unread counts per category; online recipients get a push and an optional reply flag. Bounded per account, expired by age, durable across restarts.
Seams you implement 1
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayDispatches(); and it wins.
IDispatchPolicy game SPI
What a player may do with dispatches beyond their own inbox. Today: forwarding. The default lets an account forward to any OTHER account the account store knows - register your own to limit it (allies only, same server, nobody).
-
DispatchDraft ForRecipient(long recipientAccountId, DispatchDraft draft)What recipientAccountId receives of draft: the draft itself, a modified COPY (a different payload, title parameters or folder for this recipient - e.g. the defender's view of a battle, or the attacker's without the scout details), or null to skip them (as a refused MayDeliver). Asked by Create once per recipient. Return a new draft rather than editing the one passed in: every recipient is asked with the same original. A modified copy is held to the same size limits. Defaulted (rule 25) to "the draft, if MayDeliver allows it", so an existing policy behaves as before.
-
bool MayDeliver(long recipientAccountId, DispatchDraft draft)Should recipientAccountId receive draft at all? Asked by Create once per recipient; a refused recipient is skipped silently (no copy, no id in the result, not an error). The hook for a player's own settings ("do not record scout dispatches"). Defaulted to true (rule 25), so an existing policy keeps compiling and every recipient is delivered to as before.
-
bool MayForward(long fromAccountId, long toAccountId, DispatchRecord dispatch)May fromAccountId forward dispatch to toAccountId?
Providers you can swap 2
Infrastructure seams. Crossplay ships a working implementation of each — replacing one changes where data lives or how it moves, never a game rule.
IDispatchClock provider
The clock dispatches are stamped and expired by (a seam so tests control time).
-
long UtcNowMs { get; }Now, unix ms UTC.
IDispatchStore provider
Where dispatches are kept. The default is in memory; persistence composes a document-backed one. Headers and payloads are separate so a list never reads payloads.
-
void Add(DispatchRecord dispatch)Adds a new dispatch, header and payload.
-
void Delete(long accountId, long id)Removes a dispatch and its payload.
-
IReadOnlyList<DispatchRecord> LoadHeaders(long accountId)Every dispatch one account holds (payloads may be left empty).
-
byte[] LoadPayload(long accountId, long id)One dispatch's payload, or null.
-
long NextId()The next dispatch id.
-
void SaveHeader(DispatchRecord dispatch)Saves a changed header (read, folder, keep); the payload is untouched.
Services you call 1
Crossplay implements these. Resolve them from DI and call them from your own systems.
IDispatchService
Dispatches: server-generated reports (a battle, a scouting, a trade, a match result) delivered to one or more accounts. Each recipient gets their own copy with its own read / folder / keep state. The payload is the game's, opaque here; so are the type, category and folder keys. Bounded per account (DispatchOptions), expired by age unless kept, and durable through IDispatchStore.
-
IReadOnlyList<long> Create(IReadOnlyList<long> recipientAccountIds, DispatchDraft draft)Delivers a dispatch to every recipient (duplicates and 0 ignored, and any that ForRecipient skips; each gets the draft that recipient's ForRecipient returned). Returns the new ids, one per recipient delivered to. Throws ArgumentException when the draft breaks a size limit - that is a game bug.
-
event Action<long, DispatchRecord, bool> OpenedRaised when a player opens a dispatch: (account, dispatch header, first time read).
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
DispatchOptions
Dispatches tuning (defaults here; never inlined in logic). Bound from Crossplay:Dispatches.
-
uint ArrivalReplyFlag { get; set; }Reply-flag bits raised for a recipient when a dispatch arrives, so their next request/reply carries them (Core's ReplyFlags). 0 = none.
-
string DefaultFolder { get; set; }The folder a new dispatch is filed in when its draft names none.
-
List<string> Folders { get; set; }Folder keys a client may move dispatches to. Empty = any key up to MaxKeyLength (the reserved deleted folder is reached by deleting, never by moving).
-
double ForwardCooldownSeconds { get; set; }Least time between two forwards by one account, in seconds. 0 = no limit.
-
double MaxAgeSeconds { get; set; }Age after which a dispatch that is not kept expires, in seconds. 0 = never.
-
int MaxIdsPerRequest { get; set; }Most ids one mark / move / delete / keep request may name.
-
int MaxKeptPerAccount { get; set; }Most dispatches one account may keep (a keep past this is refused). Keep it below MaxPerAccount so there is always something to evict.
-
int MaxKeyLength { get; set; }Longest type, category or folder key, in characters.
-
int MaxPayloadBytes { get; set; }Largest payload, in bytes.
-
int MaxPerAccount { get; set; }Most dispatches one account holds. A new one past this evicts the oldest deleted, then the oldest not kept.
-
int MaxRecipients { get; set; }Most recipients one Create delivers to.
-
int MaxRelatedIds { get; set; }Most related ids one dispatch carries.
-
int MaxTitleParamLength { get; set; }Longest title value, in characters.
-
int MaxTitleParams { get; set; }Most title values one dispatch carries.
-
int PageSize { get; set; }Headers per list page.
-
bool PushOnArrival { get; set; }Push DispatchArrived to a recipient who is online.
Wire messages 13
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
DispatchListRequest Client -> server: a page of my dispatches, newest first.
DispatchList Server -> client: a page of dispatch headers, newest first.
DispatchOpenRequest Client -> server: open one dispatch - its header and payload. Marks it read.
DispatchContent Server -> client: one dispatch with its payload.
DispatchMarkRequest Client -> server: mark dispatches read or unread.
DispatchMoveRequest Client -> server: move dispatches to a folder (not Deleted - delete for that).
DispatchDeleteRequest Client -> server: delete dispatches. One not yet deleted moves to Deleted; one already there is removed for good.
DispatchKeepRequest Client -> server: keep dispatches (exempt from age expiry) or release them.
DispatchForwardRequest Client -> server: forward a copy of a dispatch to another account. The copy keeps its type, category, title values, payload and time, and names you as the forwarder.
DispatchResult The answer to every dispatch change.
DispatchCountsRequest Client -> server: my unread counts per category.
DispatchCounts Server -> client: unread counts per category (only categories with something unread).
DispatchArrived Server -> client (push, when the server's PushOnArrival is on): a dispatch arrived for you.
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
IDispatchClient
Client SDK for Dispatches - server-generated reports to this account. Every verb is one awaitable call (Core's request/reply helper - a call that gets no answer completes as a timeout, it never throws), and Arrived fires when the server pushes a new one. Type, category and folder keys and the payload are the game's; turning a header into a title and a payload into a report screen is the game's too. Pure SDK, no UI.
-
event Action<DispatchHeader> ArrivedA dispatch arrived for this account (its header - open it for the payload).
-
UniTask<DispatchList> ListAsync(string folder = "", string category = "", bool unreadOnly = false, long beforeId = 0, CancellationToken ct = default)A page of my dispatches, newest first. Folder "" = every folder except deleted; category "" = any; beforeId = the last id of the previous page (0 = from the newest).
-
UniTask<DispatchContent> OpenAsync(long id, CancellationToken ct = default)Opens one dispatch (header + payload) and marks it read.
-
UniTask<DispatchResult> MarkAsync(long[] ids, bool read, CancellationToken ct = default)Marks dispatches read or unread.
-
UniTask<DispatchResult> MoveAsync(long[] ids, string folder, CancellationToken ct = default)Files dispatches in a folder.
-
UniTask<DispatchResult> DeleteAsync(long[] ids, CancellationToken ct = default)Deletes dispatches (to the deleted folder; deleting from there removes them for good).
-
UniTask<DispatchResult> KeepAsync(long[] ids, bool keep, CancellationToken ct = default)Keeps dispatches (exempt from expiry) or releases them.
-
UniTask<DispatchResult> ForwardAsync(long id, long toAccountId, CancellationToken ct = default)Forwards a copy of a dispatch to another account.
-
UniTask<DispatchCounts> CountsAsync(CancellationToken ct = default)My unread counts per category.