Astral Realms Documentation Help

Activity Recaps

A recap summarizes a shop owner's recent buy/sell activity across all of their shops: how many items moved through each shop, and how many coins the owner earned versus spent. There are two entry points — an automatic prompt shown to an owner who was away while their shops were traded at, and an on-demand /ps recap — and both compute over the same underlying transaction data via ShopService#computeRecap.

Player data: PSPlayerData

Recap state lives in a small AstralSync-backed record, com.astralrealms.playershops.storage.PSPlayerData:

Field

Type

Default

Description

recapEnabled

boolean

true

Whether this player receives the away-recap prompt and real-time trade pings. Flipped by toggle-playershops-recap.

lastLogout

long (epoch millis)

-1

Timestamp of this player's last logout/unload. -1 means "never recorded" (fresh data, no completed session yet).

It is synced across the network by PSSnapshotAdapter (UnloadableSnapshotAdapter<PSPlayerData>) under sync key astralplayershops:data:

Method

Behavior

key()

Key.key("astralplayershops", "data").

create(Player)

Fresh data for a first-ever load: new PSPlayerData(true, -1) — notifications on, no recorded logout.

apply(Player, PSPlayerData)

No-op — recap data is read on demand, nothing needs to be pushed onto the player at load time.

unload(DataHolder, Player, PSPlayerData)

Stamps lastLogout = System.currentTimeMillis(). Called by AstralSync after a save whose SnapshotCause is unloadable (disconnect or server shutdown), i.e. exactly when the player actually leaves.

Away recap

RecapListener listens for AstralSync's PlayerDataLoadedEvent, which fires whenever a player's synced data has finished loading:

AstralSync loads player data ──► PlayerDataLoadedEvent │ ▼ RecapListener.onDataLoaded │ PSPlayerData present, recapEnabled, lastLogout > 0, and (now − lastLogout) ≥ 30 000 ms? ── any false → return, no message │ yes ▼ TransactionService.findRecapsByPlayer(player, lastLogout) │ recaps empty? ── yes → return, no message │ no ▼ sum BUY-type earnings → earned · sum SELL-type earnings → spent │ ▼ RECAP_NOTIFICATION → player (%duration% %spent% %earned%) │ player clicks <click:run_command:'/ps internalrecap'> ▼ /ps internalRecap (PSCommand#onRecapInternal) │ ▼ "playershops-absent-recap" dialog opened

The elapsed-time gate is exactly System.currentTimeMillis() - playerData.lastLogout() >= 30_000L — a player who reconnects within 30 seconds of their last logout never sees the prompt, even with unseen activity. If any of the four conditions fails (no data, notifications disabled, lastLogout <= 0, or under 30 seconds elapsed) the listener returns silently; if the recap window has zero entries it also returns silently rather than sending an empty prompt.

RECAP_NOTIFICATION is sent with three placeholders registered on its container — %duration% (DurationParser.toHumanReadable of the elapsed time), %spent%, and %earned% (both NumberFormatUtils.formatWithSpaces, space-grouped whole numbers) — but the shipped messages.yml copy only interpolates %duration% in the away-message text itself; %spent%/%earned% are available to a customized message even though the default French copy doesn't reference them.

Clicking the message runs /ps internalrecap, a @Private subcommand (hidden from tab-completion) — see Commands — /ps internalRecap. It re-reads lastLogout from the player's PSPlayerData via SyncAPI.findData and recomputes the same way, opening the playershops-absent-recap dialog with the result. If lastLogout <= 0 at that point it sends RECAP_NOTHING_TO_SHOW instead.

On-demand recap: /ps recap

Any player can pull a recap of their own shops at any time with /ps recap (player-only — the handler takes a Player, not a CommandSender). Unlike the away recap, this one is not gated on recapEnabled — disabling notifications only silences the automatic prompts, not the manual command.

/ps recap │ ▼ ShopService.computeRecap(player, now − recap-interval) │ ├── computation failed → UNEXPECTED_ERROR (logged) ├── recap empty → RECAP_NOTHING_TO_SHOW │ ▼ sum BUY-type earnings → earned · sum SELL-type earnings → spent │ ▼ "playershops-daily-recap" dialog (entries, spent, earned)

The lookback window is recap-interval from Configuration — a Duration (default 24h) read from config.yml; the command computes from System.currentTimeMillis() - recapInterval.toMillis() up to now. See Commands — /ps recap for the full command reference.

Recap computation

Both entry points run through ShopService#computeRecap(Player, long timestamp), which delegates to TransactionService#findRecapsByPlayer(UUID, long) and swallows any failure into an empty list (logging the error) rather than propagating it.

TransactionService caches results in a Caffeine AsyncLoadingCache<String, List<ShopRecap>> keyed by "<playerId>:<timestamp>", 5 minutes access-expiry. A cache miss runs TransactionRepository#findRecapsForPlayer, which sums transactions.total_price and transactions.quantity, joined to shops, filtered to shops.owner_id = <player> and transactions.created_at >= timestamp, grouped by shop/type/item. The service then re-groups those rows by ItemStack across all of the owner's shops into the final list — so the shopId on each returned ShopRecap is always null (it is not meaningful once entries for the same item from different shops have been merged).

ShopRecap (com.astralrealms.playershops.model.ShopRecap) is a ComplexPlaceholder under namespace entry, used to render each row inside the recap dialogs:

Field / sub-key

Type

Description

shopId%entry_id%

UUID

Always null on recap entries (see above) — only meaningful on other ShopRecap uses.

itemStack%entry_item%

ItemStackPlaceholder

The traded item.

type%entry_type%

ShopType

BUY or SELL.

— → %entry_typeFormatted%

Component

PSMessages.BUY_TYPE or SELL_TYPE depending on type.

itemsCount%entry_count%

int

Total quantity traded for this item across the window.

earnings%entry_earnings%

double

Total coin value for this item across the window.

At both call sites (RecapListener and PSCommand) the entries are further reduced into two totals: the sum of earnings() over entries where type() == BUY becomes earned, and the sum over entries where type() == SELL becomes spent — a BUY-type shop is one where the owner stocks and sells to players (revenue in), a SELL-type shop is one where the owner buys from players (coins paid out). See Placeholders for the full %entry_*% inventory alongside %shop_*% and %transaction_*%.

Toggling recap notifications

[toggle-playershops-recap] runs ToggleRecapNotificationAction, registered with registerActionGlobally (not registerAction) — unlike most of AstralPlayerShops' actions, it is not scoped to shop-menu contexts and can be referenced from any menu's actions: list network-wide (e.g. a general player-settings menu), not just AstralPlayerShops' own configs.

It looks up the executor's PSPlayerData via SyncAPI.findData and flips recapEnabled:

  • Data found, now enabled → RECAP_ENABLED.

  • Data found, now disabled → RECAP_DISABLED.

  • No data found for the player → UNEXPECTED_ERROR.

See Actions for the full action reference.

recapEnabled gates two things, not just the away prompt: ShopService#buy/#sell also check the shop owner's recapEnabled before sending them a real-time BUY_NOTIFICATION/SELL_NOTIFICATION chat ping at trade time. Toggling this preference off silences both the live per-trade ping and the login recap prompt at once — see Shops.

Placeholder: %playershops_recapNotification%

PSPlaceholders (namespace playershops) exposes the player's current recap preference for use in menu toggle rendering (e.g. lighting up an on/off icon in a settings menu):

Placeholder

Returns

%playershops_recapNotification%

true/false from PSPlayerData.recapEnabled for the context player. false if no PSPlayerData is found for them; null if the placeholder context isn't a Player at all.

See Placeholders for the full placeholder inventory.

Messages

Message

Sent when

RECAP_NOTIFICATION

Away-recap prompt on login. See Away recap.

RECAP_ENABLED/RECAP_DISABLED

After [toggle-playershops-recap] flips recapEnabled. See Toggling recap notifications.

RECAP_NOTHING_TO_SHOW

Sent instead of opening a dialog when the computed recap has zero entries — from /ps recap, /ps internalRecap, or internalRecap with no recorded lastLogout yet.

UNEXPECTED_ERROR

Recap computation failed (logged), or [toggle-playershops-recap] couldn't find the executor's PSPlayerData.

BUY_TYPE/SELL_TYPE

The two possible values of %entry_typeFormatted%. See Recap computation.

Last modified: 25 July 2026