Economy

Mail

Offline delivery with escrowed item attachments.

Server
services.AddCrossplayMail();
Unity package
com.crossplay.mail
Depends on
core

Seams you implement 1

Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayMail(); and it wins.

IMailPolicy game SPI

SPI the GAME implements to gate every player-to-player send — where block lists, cross-faction rules, and postage costs (via Economy) live. Called BEFORE a mail is stored; returning false refuses the send (the sender gets MailNotificationCodes.SendRejected). SYSTEM mail via SendSystem bypasses this policy.

public sealed class MyMailPolicy : IMailPolicy
{
    public bool AllowSend(ISession sender, long fromCharacterId, long toCharacterId)
        => !_blocks.IsBlocked(toCharacterId, fromCharacterId); // e.g. honor the recipient's block list
}
  • bool AllowSend(ISession sender, long fromCharacterId, long toCharacterId)

    Approve (or veto) a player-to-player send.

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.

IMailRecipientResolver provider

The identity seam Mail resolves recipients through, so the piece never hard-references the Crossplay.Characters spatial piece (it only needs a name↔id directory, not character data). Two directions, both pure identity: display name → recipient owner id (TryResolve) — works OFFLINE, which is the whole point of mail (the recipient need not be online to receive it);owner id → display name (DisplayName) — the "from" line stamped on a sent mail. A character-based game bridges this to its character store (the Hosting CharactersMailRecipientResolver, zero piece-to-piece reference — the EconomyMailDailyRewardGrantor / EconomyMarketWallet pattern); a Core-only game supplies its own name directory. With neither composed, the deny default (NullMailRecipientResolver) is used, and mail-by-name fails gracefully (send-by-id / system mail still work).

  • string DisplayName(long ownerId)

    The display name to stamp as a mail's "from" for the given sender owner id; returns a stable fallback (e.g. "#42") when the id has no known name.

  • bool TryResolve(string displayName, out long ownerId)

    Resolves a recipient's display name to the abstract owner id its mailbox is keyed by. Returns false when no such recipient exists — the send is refused (TargetNotFound), never a throw.

IMailStore provider

Mail persistence SPI (in-memory default; document-backed override across restarts). Register a durable implementation with services.AddSingleton<IMailStore, MyStore>() before AddCrossplayMail (a TryAdd seam).

  • void Delete(long mailId)

    Removes a mail.

  • MailRecord Get(long mailId)

    Fetches a mail by id, or null when absent.

  • IReadOnlyList<MailRecord> ListFor(long characterId)

    Lists a recipient's mailbox, newest first.

  • long NextId()

    Allocates the next unique mail id.

  • void Save(MailRecord mail)

    Inserts or replaces a mail (upsert keyed by Id).

Services you call 1

Crossplay implements these. Resolve them from DI and call them from your own systems.

IMailService

Server-side Mail API game code calls to send SYSTEM mail — rewards, compensation, event grants — with pre-built attachments and no inventory escrow (the items are minted straight onto the mail). Player-to- player sends come over the wire instead and go through IMailPolicy. Resolve it from DI: [Inject] IMailService mail. Mailboxes are keyed by the recipient's OWNER id (the selected character id when Characters is installed, otherwise the account id).

  • long SendSystem(long toCharacterId, string fromName, string subject, byte[] body, byte[][] attachmentItems = null, int[] attachmentCounts = null)

    Sends a mail with pre-built attachments (SYSTEM mail — no inventory escrow; bypasses IMailPolicy).

Configuration 1

Every tunable lives in an options object — there are no magic numbers to hunt for.

MailOptions

Configurable mail rules (defaults here, never inline).

  • int MaxAttachments { get; set; }

    Most attachments a single mail may carry (a send exceeding this is refused).

  • int MaxBodyBytes { get; set; }

    Largest body accepted (bytes); longer bodies are truncated server-side.

  • int MaxMailsPerBox { get; set; }

    Cap on mails per mailbox; a send to a full box is refused with BoxFull.

  • int MaxSubjectLength { get; set; }

    Longest subject accepted; longer subjects are truncated server-side.

Wire messages 10

The protocol this piece speaks. Ids are allocated per piece so they can never collide.

2801 MailListRequest

Client → server: request my mailbox headers. Carries no fields; answered by a MailboxList.

2802 MailboxList

Server → client: the requester's mailbox headers, newest first. Reply to a MailListRequest, and re-pushed after box-changing operations (e.g. delete).

2803 MailOpenRequest

Client → server: open one mail (marks it read; answered by a MailContent).

2804 MailContent

Server → client: the opened mail — opaque body + opaque attachment blobs for display. Reply to a MailOpenRequest. Attachment arrays are empty once the mail has been claimed.

2805 MailClaimRequest

Client → server: claim a mail's attachments into my inventory. Answered by a MailClaimResponse; requires an item store to be installed.

2806 MailClaimResponse

Server → client: the verdict for a MailClaimRequest. Partial delivery (bag filled mid-claim) leaves the remainder attached and reports failure with the relevant reason code.

2807 MailSendRequest

Client → server: send a mail. Attachments reference MY inventory slots — the server removes the items (escrow into the mail) so items are never duplicated. Answered by a MailSendResponse.

2808 MailSendResponse

Server → client: the verdict for a MailSendRequest.

2809 MailDeleteRequest

Client → server: delete a mail. Refused while attachments remain unclaimed (nothing is destroyed); on success the server re-pushes the mailbox list.

2810 MailReceived

Server → client: a new mail arrived. This is the live-delivery push for a recipient who is online at send time; an offline recipient's mail simply waits in the box for their next MailListRequest (offline delivery is the whole point of mail).

Unity services you inject 1

Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.

IMailClient

The Mail piece, client side: list your mailbox, open a mail (opaque body — your game renders it), claim attachments into your inventory, send with attachments escrowed from your slots.

  • void RefreshMailbox()

    Requests the mailbox headers (raises MailboxReceived).

  • void Open(long mailId)

    Opens one mail (raises MailOpened).

  • void Claim(long mailId)

    Claims a mail's attachments into your inventory (raises ClaimResolved).

  • void Send(string toCharacterName, string subject, byte[] body, int[] attachmentSlots = null)

    Sends a mail; attachment slots reference YOUR inventory (escrowed by the server).

  • void Delete(long mailId)

    Deletes a mail (server refuses while unclaimed attachments remain).

  • event Action<MailHeader[]> MailboxReceived
  • event Action<MailContent> MailOpened
  • event Action<MailClaimResponse> ClaimResolved
  • event Action<MailSendResponse> SendResolved
  • event Action<MailHeader> MailArrived

UI views you can replace 1

Each ships a working uGUI panel you can drag into a scene. Substitute your own view to keep the logic and change the look — the presenter never knows.

ICrossplayMailView

Narrow contract the presenter drives. UI-only: render calls in, user intents out.