Upgrades
Coin-priced, persistent, server-authoritative meta-upgrades: price from the buyer's stored tier (ceil(InitialPrice*Growth^tier)), atomic currency debit, tier persisted per-character. Currency + persistence via seams bridged to Economy + PlayerData.
Seams you implement 2
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayUpgrades(); and it wins.
IUpgradeTierStore game SPI
The persistence seam: read / write an owner's upgrade TIER by key. A Hosting bridge binds this to IPlayerDataService (per-character, durable). Absent a bridge the default InMemoryUpgradeTierStore keeps tiers in memory (volatile) — enough for tests / local dev.
-
int GetTier(long ownerId, string tierKey)The owner's current tier for tierKey (0 when never bought).
-
void SetTier(long ownerId, string tierKey, int tier)Persists the owner's new tier for tierKey.
IUpgradeWallet game SPI
The currency seam: debit amount of currency for an upgrade purchase, atomically. The Upgrades piece never references Economy — a Hosting bridge binds this to IEconomyService.TryDebit (RULE 13). Absent a bridge the default NullUpgradeWallet denies, so purchases fail cleanly.
-
bool TryDebit(long ownerId, ushort currencyId, long amount, string reason)Debits the price atomically; false when the owner can't afford it (nothing changes).
Services you call 1
Crossplay implements these. Resolve them from DI and call them from your own systems.
IUpgradeService
Server-authoritative upgrade purchases. The client sends only an upgrade id; this service owns the price (derived from the buyer's persisted tier), the atomic currency debit, and the tier bump — so a client can never forge a price or a tier. Currency + persistence come through the IUpgradeWallet / IUpgradeTierStore seams (a Hosting bridge binds them to Economy + PlayerData).
-
UpgradeStatusEntry[] StatusOf(ISession session)Snapshots the session owner's current tier + next price for every catalog upgrade — the read path the shop UI uses to bootstrap (initial open / reconnect) without a purchase. Empty when the session has no bound owner.
-
bool TryPurchase(ISession session, ushort upgradeId, out int newTier, out ushort reasonCode)Buys the NEXT tier of upgradeId for the session's owner: prices it from the stored tier, debits the currency atomically, and persists tier + 1. Returns true with the new tier on success; false with a UpgradeNotificationCodes reason otherwise (nothing changes).
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
UpgradeOptions
The upgrade catalog (upgrade id → UpgradeDef). Empty = the piece is inert.
-
Dictionary<ushort, UpgradeDef> Catalog { get; set; }Upgrade id → its definition.
Wire messages 4
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
UpgradePurchaseRequest Client → server: buy the NEXT tier of an upgrade. The client sends only the upgrade id — the server owns the price (derived from the buyer's persisted tier), the currency, the debit, and the tier bump, so a client can never forge a price or a tier. The UpgradeId is a game-defined catalog key.
UpgradePurchaseResult Server → client: the outcome of an UpgradePurchaseRequest.
UpgradesStatusRequest Client → server: send me my current tier + next price for every upgrade — the shop UI bootstrap (initial open, or a re-open after reconnect, without making a purchase).
UpgradesStatusResponse Server → client: the per-upgrade tier/price snapshot (the whole catalog, not a delta).
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
IUpgradeClient
The Upgrades piece, client side: buy the next tier of a coin-priced meta-upgrade. The client sends only the upgrade id — the server owns the price (from the buyer's persisted tier), the currency debit, and the tier bump — so nothing here can be forged. The reply carries the new tier or a refusal reason code; the game reads the tier and applies whatever it means (the upgrade's effect is the game's).
-
UniTask<UpgradePurchaseResult> PurchaseAsync(ushort upgradeId, CancellationToken ct = default)Buys the next tier of upgradeId (refusals carry the reason code).