1 player notification inbox authority and delivery
forgejo-actions edited this page 2026-07-15 12:43:13 +00:00

Player Notification Inbox Authority And Delivery

The player notification inbox is a durable, character-scoped record of actionable messages. The game server owns delivery and state transitions, PostgreSQL owns persistence, and clients receive a read model inside GameplayConditionSnapshot.

Durable Inbox Versus Ephemeral UI Notifications

These are two different runtime mechanisms:

Mechanism Owner Lifetime Current inputs
PlayerNotificationInboxSnapshot Game server and PostgreSQL Survives disconnects and process restarts until archived, claimed, expired, or deleted with the character Currently produced by housing bid and auction authority
PlayerGameplayUiState.notifications Client gameplay UI Bounded in-memory presentation queue Discovery, unlock, runtime event, combat reward, and titles of newly observed unread inbox entries

The ephemeral queue is not the inbox. It keeps at most eight entries, deduplicates only consecutive equivalent entries, and has no read, archive, preference, or claim state. A newly replicated durable inbox entry may create an ephemeral notice, but dismissing or evicting that notice does not mutate the durable entry.

There is no separate server toast packet in the current path. The server replicates the durable inbox, and the client derives a short-lived event notice when a changed inbox revision contains a previously unseen unread entry.

Ownership And Data Flow

  1. A producer creates NotificationDelivery with a template id and character-local dedupe key. It may add render variables, an action target, source identity, and claimable state.
  2. deliver_player_notification_in_transaction resolves the template from the validated shipped catalog, checks the character's category preference, renders title and body, and inserts the rendered entry in the caller's SQL transaction.
  3. A successful insert increments player_notification_inbox_state.revision. A duplicate (character_id, dedupe_key) is ignored and does not advance the inbox revision.
  4. Session activation loads the inbox from the dedicated notification tables into authoritative gameplay state. Failure is logged and activation continues with the projection it already had.
  5. Online refresh publishes a new GameplayConditionSnapshot through the active realtime session actor. Inbox commands use the normal gameplay-action request, result, replay, and idempotency path.
  6. The client applies the snapshot only when the inbox revision changes, keeps selection valid, and renders the inbox panel. Client actions submit commands; they never directly rewrite the replicated snapshot.

load_player_notification_inbox returns at most 100 non-archived, non-expired entries ordered newest first. unread_count is counted across every active unread row, including entries beyond that display window. Expired rows remain durable rows but are omitted from the read model.

Current Producer And Consumer Boundary

Housing authority is the only current durable producer. It creates housing.bid_placed, housing.outbid, housing.auction_won, and housing.escrow_refunded entries in the same transaction as the associated bid, escrow, settlement, or refund work. The shipped catalog also contains housing.access_changed, but no current producer calls it.

The generic catalog and wire contract already describe open_housing, open_quest, open_map, open_inventory, and open_encounter_lobby actions. Those action kinds are capabilities, not evidence that every owning gameplay system currently produces inbox entries.

The client currently consumes the full inbox and supports:

  • refresh;
  • mark one or many unread entries read;
  • mark all read;
  • archive one or many non-claimed entries;
  • claim one claimable unread or read entry;
  • enable or disable future delivery for a category;
  • route an entry action to the housing, quest, map, inventory, or encounter-lobby presentation path.

Claim currently performs the authoritative inbox state transition only. No reward grant is attached to PlayerNotificationCommand::Claim, and current housing deliveries are not claimable.

Lifecycle And State Transitions

Entries are inserted as unread and can follow these transitions:

unread -> read -> archived
   |         |
   +-------> claimed    (only when claimable = true)
   +-------> archived

There is no command to return an entry to unread. Claimed entries cannot be archived by the current SQL update. Archived and expired entries are omitted from server snapshots; category preferences do not retroactively archive existing messages.

Inbox revision changes when a delivery is inserted, an entry command changes rows, or a category preference is written. Refresh does not change revision. Because the client ignores inbox snapshots whose revision matches its current projection, expiry alone and a same-revision refresh do not remove an already displayed entry; another mutation must advance the revision, or the client application rule must change. Per-entry and per-preference revision columns support storage evolution, while player_notification_inbox_state.revision is the client projection change token.

Adding A Producer Or Template

  1. Add a unique template to assets/gameplay/player_notification_catalog.json. Category, title, and body are required; priority, icon, action kind, and expiry are optional.
  2. Add or extend domain and wire action enums only if the existing action kinds cannot express the navigation target. Update both state and action codecs with the protocol change.
  3. Create a stable dedupe key for one logical delivery. Retries of that logical event must reuse it.
  4. Populate every template variable and typed action field before delivery. Unmatched {{variable}} tokens are not rejected by the renderer.
  5. Prefer deliver_player_notification_in_transaction when the notification describes a durable mutation. This prevents a message from committing when its source mutation rolls back.
  6. Set source_kind and source_id if online recipients must be refreshed after the transaction commits.
  7. Add client routing only when a new action kind is introduced. Presentation routing must not perform the authoritative mutation represented by the message.

Rules And Invariants

  • Inbox authority is character-scoped; every update filters by character_id as well as notification id.
  • Dedupe keys must identify a logical event within one character, not one delivery attempt.
  • Disabled categories suppress future insertion and therefore do not bump the inbox revision.
  • Batch mark-read and archive commands accept at most 100 UUIDs. Category keys are lowercase, at most 64 bytes, and limited to ASCII letters, digits, ., _, and -.
  • Rendered text is stored at delivery time. Later catalog edits do not rewrite existing rows.
  • An inbox update is published as authoritative gameplay conditions before its GameplayActionResult is sent for a command.
  • Client selection, panel state, and ephemeral notices are presentation state and must never be treated as durable inbox authority.

Validation And Debugging

Run the focused unit coverage when changing templates or command validation:

cargo test -p aether-world-domain shipped_templates_render_typed_housing_messages
cargo test -p aether-game-server notification_categories_are_storage_safe
cargo test -p aether-game-server notification_batch_limit_is_enforced

For a missing delivery, inspect the source transaction, template id, category preference, dedupe key, and inbox revision in that order. Useful storage checks are:

SELECT notification_id, template_id, category, state, claimable,
       dedupe_key, source_kind, source_id, created_at_ms, expires_at_ms
FROM player_notifications
WHERE character_id = $1
ORDER BY created_at_ms DESC;

SELECT revision, updated_at
FROM player_notification_inbox_state
WHERE character_id = $1;

SELECT category, enabled, revision
FROM player_notification_preferences
WHERE character_id = $1
ORDER BY category;

Interpret common failures as follows:

  • Row absent, revision unchanged: template resolution, disabled category, source rollback, or dedupe conflict.
  • Row present, online UI stale: recipient publication, active-session lookup, gameplay-condition packet delivery, or unchanged client revision.
  • Command rejected: invalid UUID, more than 100 ids, invalid category, non-claimable entry, or an already claimed/missing entry.
  • Inbox correct but no short-lived notice: the first inbox initialization intentionally does not emit notices for all existing unread entries; only later newly observed unread ids do.

Example Delivery

Housing bid authority uses a delivery shaped like this:

let mut delivery = NotificationDelivery::new(
    "housing.outbid",
    format!("housing:outbid:{request_id}:{character_id}"),
);
delivery.variables = BTreeMap::from([
    ("amount".to_string(), amount.to_string()),
    ("plot".to_string(), plot_label),
]);
delivery.action = PlayerNotificationAction {
    kind: PlayerNotificationActionKind::OpenHousing,
    area_id,
    instance_id,
    plot_id,
    ..Default::default()
};
delivery.source_kind = "housing_bid".to_string();
delivery.source_id = request_id;

The important operational property is not the exact labels: the domain mutation and inbox insert share a transaction, while online publication happens after commit.