Daily Rewards
Interval claims with grace-window streaks; reward is an IDailyRewardGrantor game SPI.
Seams you implement 1
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayDailyRewards(); and it wins.
IDailyRewardGrantor game SPI
THE game hook: what a daily reward IS. The piece owns the interval/streak MECHANIC; this SPI owns the PAYOUT. Implement it to decide what a claim gives; the returned byte[] is the OPAQUE blob echoed to the client in DailyClaimResponse.RewardData — the framework never interprets it, so one game encodes coins, another a chest id, another a text line.
public sealed class MyGrantor : IDailyRewardGrantor
{
private readonly IEconomyService _economy;
public MyGrantor(IEconomyService economy) => _economy = economy;
public byte[] Grant(ISession session, int streak)
{
int coins = 50 + (streak - 1) * 10; // more per streak day
_economy.TryCredit(session, GoldCurrencyId, coins, "dailyreward");
return BitConverter.GetBytes(coins); // opaque blob the client renders
}
} -
byte[] Grant(ISession session, int streak)Produces the reward for one successful claim and returns the opaque blob sent to the client.
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.
IDailyClock provider
Wall-clock seam (unix-epoch UTC milliseconds) so the interval/streak math is testable and restart-safe. Piece-local on purpose: feature pieces depend on Core only, never on the Scheduler piece. Override it (a fake clock) in tests to advance time without waiting; the default SystemDailyClock reads the real system clock.
-
long UtcNowMs { get; }The current time as unix-epoch milliseconds, UTC.
IDailyRewardStore provider
Claim-state persistence SPI, keyed by the owner (character) id. The default InMemoryDailyRewardStore keeps records in RAM (dev/tests); register a document-backed override (e.g. via AddCrossplayPersistentStores) BEFORE AddCrossplayDailyRewards to survive restarts — it is a TryAdd seam.
-
DailyClaimRecord Get(long characterId)Loads the owner's claim record, or null if they have never claimed.
-
void Save(DailyClaimRecord record)Upserts the owner's claim record (called after each successful claim).
Services you call 1
Crossplay implements these. Resolve them from DI and call them from your own systems.
IDailyRewardService
Server-side claim/streak API — the interface game code CALLS (and the wire handlers sit on top of). Resolve it from DI: [Inject] IDailyRewardService rewards. All timing is server-authoritative; clients only request a claim or read status over the wire.
-
DailyStatus StatusOf(ISession session)Reads the claimability/streak/countdown for the session's owner without claiming.
-
bool TryClaim(ISession session, out int streak, out byte[] rewardData, out ushort reason)Attempts a claim for the session's owner, advancing or resetting the streak per the interval/grace rules.
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
DailyRewardOptions
Tuning for the DailyRewards piece — the interval-claim + grace-window streak mechanic (defaults here, never inline). Passed to the configure callback of AddCrossplayDailyRewards.
-
long BridgeBaseAmount { get; set; }Base amount credited per claim.
-
ushort BridgeCurrencyId { get; set; }Currency the bridge credits per claim. 0 = the bridge is inert (SPI default applies).
-
string BridgeMailFrom { get; set; }Sender name on milestone mail (the game's fiction, not a framework string).
-
string BridgeMailSubject { get; set; }Subject on milestone mail.
-
int BridgeMilestoneEvery { get; set; }Every Nth streak day also sends a milestone mail (0 = never).
-
int BridgeMilestoneItemCount { get; set; }Attachment count for the milestone item.
-
byte BridgeMilestoneItemKind { get; set; }Opaque item kind attached to milestone mail (0 = mail without attachment).
-
long BridgePerStreakBonus { get; set; }Extra amount per streak day beyond the first (day N pays base + (N-1)×bonus).
-
double ClaimIntervalHours { get; set; }Minimum hours between successful claims. A claim inside this window is refused with AlreadyClaimed. Default 20 — a classic daily with slack for drifting play times.
-
int MaxStreak { get; set; }Upper bound the streak counter is clamped to. 0 = unbounded (grow forever).
-
double StreakGraceHours { get; set; }Grace window, in hours measured from the LAST claim: claiming within it continues the streak, claiming after it resets the streak to 1. Keep it longer than ClaimIntervalHours (default 48).
Wire messages 4
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
DailyClaimRequest Client → server: claim the current interval's reward for the selected character. Carries no fields — the server resolves the claimant's owner id from the authenticated session and applies the interval/streak rules. The reply is a DailyClaimResponse (plus a fresh DailyStatus).
DailyClaimResponse Server → client: the verdict for a DailyClaimRequest. RewardData is the game's opaque blob (the framework never knows what a reward IS — the game's IDailyRewardGrantor produced it, the game's client renders it: coins, a chest animation, a text line…).
DailyStatusRequest Client → server: ask for the current streak/claimability without claiming. Carries no fields (owner resolved from the session); the reply is a DailyStatus.
DailyStatus Server → client: the streak/countdown snapshot — sent as the reply to a DailyStatusRequest and, unsolicited, immediately after every successful claim so the client's countdown refreshes.
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
IDailyRewardClient
The DailyRewards piece, client side: claim the interval reward and follow the streak. The reward blob in the claim response is the GAME's vocabulary — this client never interprets it.
-
UniTask<DailyClaimResponse> ClaimAsync(CancellationToken ct = default)Claims today's reward (refusals carry the reason code).
-
UniTask<DailyStatus> StatusAsync(CancellationToken ct = default)Requests the current streak status.
-
event Action<DailyStatus> StatusChangedThe latest status push (after requests and successful claims).
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.
ICrossplayDailyRewardsView
Narrow contract the presenter drives. UI-only: render calls in, user intents out.