GameDevPartner SDK — AI Prompt

Скопируйте этот промпт в ChatGPT, Claude или другую нейросеть — она интегрирует SDK в ваш Unity-проект.

# GameDevPartner Unity SDK — Integration Guide for AI (v2.20.1)

You are integrating the GameDevPartner SDK into a Unity mobile game (Android/iOS).
The SDK tracks installs, in-app purchases, and ad impressions so the platform can attribute
revenue back to the influencer who brought the player. Since v2.8.0 it also collects
in-game analytics (sessions, retention, economy, funnels) shown in the developer cabinet.

## Package identity — AUTHORITATIVE, do NOT guess and do NOT ask the user to confirm

These values are exact. Use them directly — never stop to ask the developer to verify them
or to read package.json from the git repo; that is already done for you here:

- UPM package name (manifest.json key): `com.gamedevpartner.sdk`
- Git URL (always with the version tag): `https://gitlab.com/nonstop1899/gamedevpartner-unity-sdk.git#v2.20.1`
- C# namespace: `GameDevPartner.SDK`
- Main entry class: `GameDevPartnerSDK` (static; auto-initializes via [RuntimeInitializeOnLoadMethod])
- Config asset: `GameDevPartnerConfig` ScriptableObject in a Resources/ folder
- Minimum Unity: 2020.3
- Current version: 2.20.1

When wiring code, `using GameDevPartner.SDK;` and call `GameDevPartnerSDK.IdentifyPlayer(...)`,
`GameDevPartnerSDK.TrackPurchase(...)`, `GameDevPartnerSDK.TrackAdRevenue(...)`. Do not invent
other namespaces or class names.

## Platform architecture you must understand

- The SDK runs inside the game. It auto-identifies the player by device ID on first launch,
  reads install referrer, signs every request with HMAC-SHA256, and sends events to
  `gamedevpartner.ru`.
- Purchases are posted by the game code (one `TrackPurchase` call per purchase).
- Ad impressions are posted by the game code (one `TrackAdRevenue` call per impression).
  For networks that expose per-impression revenue in their callback, pass that value.
  For Unity Ads standard SDK (which does NOT expose revenue), pass revenue=0 — the platform
  pulls exact revenue nightly from Unity Monetization Stats API and distributes it across
  SDK-recorded impressions. The developer connects Reporting credentials in the dashboard.

## Installation — prefer the scoped registry

Two ways exist. **Default to the registry** unless the developer says otherwise: with it the
package gets a version dropdown and a working Update button in Package Manager, so future updates
are one click. A git URL pinned to a tag can only be updated by editing the manifest by hand.

Add to `Packages/manifest.json` (registry is public, no token):

```json
{
  "scopedRegistries": [
    {
      "name": "GameDevPartner",
      "url": "https://gamedevpartner.com/api/v1/upm",
      "scopes": ["com.gamedevpartner"]
    }
  ],
  "dependencies": { "com.gamedevpartner.sdk": "2.20.1" }
}
```

Use the .com address for Google Play / App Store builds; projects already set up with gamedevpartner.ru
keep working. If the package does not download and Unity reports `connect ETIMEDOUT` for
com.gamedevpartner.sdk, set the registry url to GitLab directly:
`https://gitlab.com/api/v4/projects/81635489/packages/npm/` — same package. With the Package Manager window open the console
may show InvalidCastException; it does not affect the build.

The registry URL is a thin proxy to the same GitLab registry — NOT gitlab.com directly.
GitLab does not implement the npm search endpoint, so pointing Unity straight at it makes
Package Manager throw `InvalidCastException` every editor tick while its window is open.
The proxy silences search; version list and the Update button work normally; tarballs come
straight from GitLab. If a project has the old `gitlab.com/...` URL in its manifest, replace
the `url` with the one above — nothing else changes.

Git URL alternative (only if the developer refuses to touch scopedRegistries):
`https://gitlab.com/nonstop1899/gamedevpartner-unity-sdk.git#v2.20.1`

## Installation

### Fresh install
Unity Package Manager → "+" → "Add package from git URL":
```
https://gitlab.com/nonstop1899/gamedevpartner-unity-sdk.git#v2.20.1
```

**Never hand the developer this URL without the `#v…` tag.** Unity pins the resolved commit in
`Packages/packages-lock.json` and from then on considers the package up to date forever — a
tagless install silently freezes the project on whatever version existed that day, and no button
in the UI will move it. Seen in the wild (Sep 2026): a live project sitting on 2.16.7 while the
current release was 2.18.1, which is why its Region setting did nothing.

### Updating from an earlier version — DO NOT just click "Update"
Unity's Package Manager often keeps a stale cached copy of git-based packages. After
"Update", the PackageCache folder may still contain the old commit hash, and none of
the fixes in the new version will be applied. Always full-reset:

1. Window → Package Manager → select GameDevPartner SDK → **Remove**
2. Close Unity
3. Manually delete `<ProjectRoot>/Library/PackageCache/com.gamedevpartner.sdk@*` (any folders
   starting with that name)
4. Reopen Unity and let it finish compiling
5. Window → Package Manager → "+" → "Add package from git URL" → paste the URL again → Add

### Requirements
- Unity 2020.3 or newer.
- No other Unity packages are required.
- Unity IAP is OPTIONAL. If it's in the project (package `com.unity.purchasing`), the SDK
  compiles an extra sub-assembly `GameDevPartner.SDK.IAP` that exposes the
  `GDPUnityIAP` helper class. If Unity IAP is NOT installed, that sub-assembly is skipped
  entirely via asmdef `defineConstraints` — no missing-reference errors, no cascade
  failures, `Window → GameDevPartner → Settings` menu still appears normally.

### Verify install succeeded
After Package Manager finishes importing:
- Console has zero errors from `Library\PackageCache\com.gamedevpartner.sdk@...`
- Menu `Window → GameDevPartner → Settings` exists
- Opening it shows the settings window with an `API Key` field

If `Window → GameDevPartner` does NOT appear, the runtime asmdef failed to compile.
Check the Console for the first SDK-related error (ignore `.meta` GUID warnings from
your own Assets — those are unrelated). Then redo the full-reset steps above.

## Setup (one-time)

1. Window → GameDevPartner → Settings
2. Paste the API Key from gamedevpartner.ru → Developer → Мои игры → the game's card → "API-ключ".
   ONE key per game — the same key in every store build (RuStore, Google Play, App Store).
3. Region — **derive it from the store, do not ask the developer to guess**:
   - RuStore build → `RU` (ingest `api.gamedevpartner.ru`)
   - Google Play build → `World` (ingest `api.gamedevpartner.com`)
   - App Store build → `World` (ingest `api.gamedevpartner.com`)

   Both contours write to the SAME database: one dashboard, one API key, same reports. Region
   changes the ingest address and nothing else, so a "wrong" Region loses nothing and breaks
   nothing — never present this as a risky setting. Requires **2.18.0+**; builds on 2.12.2–2.17.3
   ignore Region and always send to `.ru` (fail-safe from before the World contour existed).
   If the developer set `World` but events land on RU, the package is older than 2.18.0 —
   have them check the init log line `Region=… Url=…`.
4. Store: this build's store — `RuStore` / `GooglePlay` / `AppStore`. Splits all analytics by store
   (RuStore and Google Play are both android, indistinguishable by platform). Set per build. (2.11.0+)
5. Auto Identify Player: ON (recommended; uses device ID)
6. Debug Mode: ON while developing, OFF for production builds

## Multi-store: one game shipped to RuStore + Google Play + App Store

If the same game ships to more than one store, the developer wants analytics split BY STORE.
Critical fact you must not get wrong: **RuStore and Google Play are BOTH `platform = android`** —
platform cannot tell them apart. The **Store** setting is what splits them.

- **One API key for all store builds.** A game has one key; put the SAME key in every build.
  Do NOT create separate GameDevPartner games per store and do NOT ask for a second key.
- **Set Store per build** in Settings (`RuStore` / `GooglePlay` / `AppStore`). Each build carries
  its store on every event, purchase and ad impression, and the whole dashboard (revenue, DAU,
  retention, ads) breaks down RuStore vs Google Play vs App Store in one project.
- **Store is independent of the payment Source.** A RuStore build may take money via YooKassa →
  `Store=RuStore`, `Source=PaymentSource.YooKassa`. Do not derive one from the other.
- With the Unity IAP wrapper (`GDPUnityIAP`), the purchase Source is taken from the configured
  Store automatically (RuStore build → RuStore source). With manual `TrackPurchase`, pass the
  correct `Source` yourself.

If the developer says "I already integrated the RuStore build and now I'm doing Google Play/iOS":
same key, set Store to `GooglePlay`/`AppStore` **and Region to `World`** in that build's
Settings, then rebuild. Nothing else changes — no code edits, no second key.

## The SDK must never be able to break the game — do not add defensive plumbing

Analytics is not allowed to affect gameplay, and the SDK already guarantees this. Verified
behaviour you can rely on and should tell the developer about:

- **No config asset in the project** → the SDK silently does not initialise. No error, no exception.
- **Empty API key** → one warning in the log, then silence.
- **Any `GameDevPartnerSDK.*` call before init** → early return. Nothing is thrown.
- **Server unreachable / 5xx / hung connection** → the batch stays in the offline queue and is
  retried later. The queue is capped (500 events), so memory does not grow without bound.
- **Requests do not wait forever** — 20 s timeout since 2.18.1. Before that a hung socket could
  silently stop all analytics for the rest of the session (the game kept running, the dashboard
  stayed empty, and nothing in the log connected the two).

Consequences for how you write the integration:

- **Do NOT wrap `GameDevPartnerSDK` calls in try/catch** "just in case". They do not throw, and the
  catch blocks only hide real integration mistakes (wrong key, missing config) that the log would
  otherwise name.
- **Do NOT gate gameplay on the SDK**: never await a tracking call, never block a scene load,
  a reward grant or a purchase completion on analytics succeeding. Tracking is fire-and-forget.
- **Do NOT add your own retry/queue layer.** There already is one, with a cap and disk persistence.
  A second one duplicates events and fights the first.
- **Do NOT add a network-availability check before calling the SDK.** Offline is a normal state
  for it — the event is queued and sent when connectivity returns.

## Subscriptions — the most common integration gap

**Subscriptions are purchases too. Call `TrackPurchase` for them exactly as for consumables.**
This is the single most frequent omission: a game reports coin packs and offers but stays silent
about subscriptions, and the developer then sees the store console showing a paid subscription
that never appears in the dashboard. Real case (Aug 2026): a 3990 ₽ yearly RuStore subscription
was paid and never reported — the game simply had no `TrackPurchase` call on that code path.

Rules:

- Report the **initial purchase** on the success callback, same as any other product.
- Report **every renewal you observe** — when the store SDK delivers a renewed purchase on app
  start (Unity IAP replays owned subscriptions; RuStore and Google return active subscriptions
  on query), post it with the renewal's own transaction id. The platform deduplicates by
  transaction id, so re-posting the same one is harmless — but skipping it loses the revenue.
- `TransactionId` **may be empty for subscriptions** (RuStore returns `invoiceId` /
  `subscriptionToken` instead of a classic order id). Pass what you have and always pass
  `ReceiptData` — the platform derives a stable id from the receipt. Never skip a purchase
  because you have no transaction id.
- **SDK 2.14.0 adds three optional fields** on `PurchaseEvent`. Set them when you know them —
  they are what turns a payment into a tracked subscription with MRR and churn:

```csharp
GameDevPartnerSDK.TrackPurchase(new PurchaseEvent {
    ProductId = "vip.monthly",
    Amount = 599f, Currency = "RUB",
    Source = PaymentSource.RuStore,
    TransactionId = invoiceId,
    ReceiptData = rawReceiptJson,
    IsSubscription = true,            // this payment is a subscription
    SubscriptionPeriodDays = 30,      // 7 / 30 / 365 — needed to normalise MRR
    ExpiresAt = "2026-09-01T12:00:00Z" // ISO-8601, optional; the platform computes it otherwise
});
```

  If you omit them, the platform still infers subscription-ness from the receipt
  (RuStore `subscriptionToken`, Google `autoRenewing`, Apple `expires_date`) and from the
  product id — but the explicit flag is more reliable than any heuristic.
- **Do NOT try to decide whether a payment is a new subscription or a renewal.** Just report the
  payment. The platform derives new / renewal / resubscribe-after-a-break from the chain state.
  This also means manual (non-auto-renewing) subscriptions — where the player pays again by hand
  every period — become correct renewal chains with no extra work.

**What the platform cannot see without you — be honest about this.** A renewal charged while the
app is closed happens entirely on the store's servers; the client never runs, so `TrackPurchase`
never fires. Coverage per provider:

| Provider | Initial purchase | Renewals | Cancel / expiry |
|---|---|---|---|
| YooKassa, T-Bank, web, manual "pay again" | client SDK | client SDK (the player pays in-app) | S2S webhook |
| RuStore | client SDK | **needs S2S** | **needs S2S** |
| Google Play | client SDK | client SDK only if the app opens (RTDN is authoritative) | **needs S2S** |
| App Store | client SDK | client SDK only if the app opens (ASSN is authoritative) | **needs S2S** |

To close the gap, the developer's backend forwards the store webhook to:

```
POST https://gamedevpartner.ru/api/v1/subscriptions/event
Authorization: Bearer <game API key>
Content-Type: application/json

{ "player_id": "...", "product_id": "vip.monthly",
  "event_type": "renewed",           // purchased | renewed | canceled | expired | refunded | grace | on_hold
  "amount": 599, "currency": "RUB", "period_days": 30,
  "transaction_id": "...", "source": "rustore",
  "occurred_at": "2026-09-01T12:00:00Z" }
```

The same transaction id sent by both the client and the webhook is deduplicated — forward
everything, duplicates are free. Without the webhook, RuStore/Google/Apple renewals are simply
not visible, and the dashboard says so out loud instead of showing a falsely low renewal count.
Subscriptions are shown as a separate revenue line, so gaps are obvious.

## Server-side purchases (web shops) — POST /api/v1/purchases/event

If the player pays on the developer's WEBSITE (web shop, T-Bank/YooKassa page) and the grant is
server-side, the client never learns about the purchase — `TrackPurchase` never fires. For such
payments there is a server mirror of the client track:

```
POST https://gamedevpartner.ru/api/v1/purchases/event
Authorization: Bearer <game API key>
Content-Type: application/json

{ "player_id": "...", "product_id": "web_pack_500",
  "amount": 499, "currency": "RUB", "source": "tbank",
  "transaction_id": "...", "occurred_at": "2026-09-01T12:00:00Z" }
```

- Processing is THE SAME code path as a client purchase: influencer attribution, anti-fraud,
  receipt validation and dedup are shared. The same transaction from the client and the webhook
  collapses into one; a duplicate returns 200 with `dedup: true` — forward everything.
- `player_id` must be the same identifier the game sends via the SDK, or the purchase will not
  attach to the referred player.
- Optional subscription fields (`is_subscription`, `subscription_period_days`, `expires_at`)
  make the payment advance the subscription chain exactly like a client purchase would.

## Namespaces

```csharp
using GameDevPartner.SDK;
// Main class: GameDevPartnerSDK
// DTOs: PurchaseEvent, PaymentSource
// Config enums: SDKRegion (RU | World), GDPStore (RuStore | GooglePlay | AppStore | Other)
```

## What the SDK does automatically — don't re-implement

- Identifies the player (device ID) on first launch
- Reads Google Play / RuStore Install Referrer for attribution
- Signs requests (HMAC-SHA256 with ApiKey + timestamp)
- Queues purchases offline (up to 100 events), retries on reconnect

Never call `GameDevPartnerSDK.Init()` — auto-init via `[RuntimeInitializeOnLoadMethod]`.
Do not call `IdentifyPlayer()` with a device id or a random id — Auto Identify already does that.
DO call it with the game's OWN stable id (account, cloud save) — see «Player identity» below.

==================================================================
## Purchase tracking — mandatory
==================================================================

### Option A: Unity IAP (the one-line integration)

Replace `UnityPurchasing.Initialize(this, builder)` with the SDK wrapper:

```csharp
using GameDevPartner.SDK;
// before: UnityPurchasing.Initialize(this, builder);
GDPUnityIAP.Initialize(this, builder);
```

That's it. Every successful Google Play / App Store purchase is tracked automatically
with the correct `TransactionId`, `Amount`, `Currency`, and `ReceiptData`.

### Option B: Manual tracking (YooKassa, TBank, RuStore, Web, custom)

Call `TrackPurchase` exactly once per successful payment, inside the success callback:

```csharp
using GameDevPartner.SDK;

GameDevPartnerSDK.TrackPurchase(new PurchaseEvent {
    ProductId = "gem_pack_100",               // your product ID
    Amount = 99f,                              // gross price the player paid (before store commission)
    Currency = "RUB",                          // ISO 4217
    Source = PaymentSource.YooKassa,           // see table below
    TransactionId = paymentId,                 // unique ID from the payment system
    ReceiptData = receiptOrNull,               // optional, for server-side validation
});
```

### PaymentSource values (enum)

| Value | When to use |
|---|---|
| `PaymentSource.GooglePlay` | Google Play Billing (Unity IAP uses this automatically) |
| `PaymentSource.Apple` | Apple StoreKit (Unity IAP uses this automatically) |
| `PaymentSource.YooKassa` | YooKassa SDK |
| `PaymentSource.TBank` | TBank / Tinkoff payments |
| `PaymentSource.RuStore` | RuStore billing |
| `PaymentSource.Web` | Web checkout (Stripe, PayPal, etc.) |
| `PaymentSource.Other` | Anything else |

### Multiple payment rails in one game (the RU norm)

`GDPUnityIAP.Initialize` covers ONLY Unity IAP. A typical RU game runs Unity IAP + YooKassa +
TBank + RuStore at once. Do NOT instrument each site separately and do NOT make the developer
guess PaymentSource each time. Wrap it once and call from every non-Unity-IAP success callback:

```csharp
void TrackGdpPurchase(string productId, float amount, string currency,
                      string txId, PaymentSource source) {
    GameDevPartnerSDK.TrackPurchase(new PurchaseEvent {
        ProductId = productId, Amount = amount, Currency = currency,
        TransactionId = txId, Source = source,
    });
}

// YooKassa success:  TrackGdpPurchase(productId, amount, "RUB", payment.Id, PaymentSource.YooKassa);
// TBank success:     TrackGdpPurchase(productId, amount, "RUB", tbankId,    PaymentSource.TBank);
// RuStore Billing:   TrackGdpPurchase(sku,       price,  "RUB", purchaseId, PaymentSource.RuStore);
// Unity IAP is NOT here — it is covered by GDPUnityIAP.Initialize.
```

The manual single-entry pattern is FIRST-CLASS, not a fallback. `Source` (how they paid) is
separate from `Store` (which store build): a RuStore build can charge via YooKassa →
`Store=RuStore`, `Source=YooKassa`.

==================================================================
## Ad revenue tracking — mandatory if the game shows ads
==================================================================

Single universal method with POSITIONAL arguments (no `new AdRevenueEvent {}` class):

```csharp
GameDevPartnerSDK.TrackAdRevenue(
    double revenue,   // amount per impression; pass 0 for Unity Ads standard (see below)
    string currency,  // "USD" | "RUB" | ISO 4217
    string adType,    // "rewarded" | "interstitial" | "banner"
    string adNetwork, // string (not enum): "yandex_ads" | "unity_ads" | "admob" | "ironsource" | "applovin" | "other"
    string adUnitId   // optional, ad unit / placement ID
);
```

### adNetwork values — EXACT strings (not enums)

`"yandex_ads"`, `"unity_ads"`, `"admob"`, `"ironsource"`, `"applovin"`, `"other"`

### Two callback scenarios — pick the right one per ad network

#### Scenario 1: networks that expose revenue in the impression callback (ILAR)

AppLovin MAX, IronSource (LevelPlay), AdMob, Yandex Ads with mediation — pass the real
per-impression revenue:

```csharp
// Yandex Ads (mediation) — impression callback carries rawData JSON:
rewardedAd.OnAdImpression += (sender, data) => {
    var json = JsonUtility.FromJson<ImpressionJson>(data.rawData);
    GameDevPartnerSDK.TrackAdRevenue(json.revenue, json.currency, "rewarded", "yandex_ads", adUnitId);
};

// AppLovin MAX:
MaxSdkCallbacks.Rewarded.OnAdRevenuePaidEvent += (id, info) =>
    GameDevPartnerSDK.TrackAdRevenue(info.Revenue, "USD", "rewarded", "applovin", id);

// IronSource (LevelPlay):
IronSourceEvents.onImpressionDataReadyEvent += (data) =>
    GameDevPartnerSDK.TrackAdRevenue(data.revenue ?? 0, "USD", data.adUnit, "ironsource");

// AdMob — value arrives in micros, divide by 1,000,000:
rewardedAd.OnAdPaid += (adValue) =>
    GameDevPartnerSDK.TrackAdRevenue(adValue.Value / 1000000.0, adValue.CurrencyCode, "rewarded", "admob", adUnitId);
```

#### Scenario 2: Unity Ads standard SDK (NOT LevelPlay)

The Unity Ads standard SDK does NOT expose per-impression revenue in any callback
(no `data.revenue`, no `ImpressionEventPublisher` — do not invent these).

Instead:

a) The developer connects Unity Ads in the GameDevPartner dashboard:
   ЛК → Мои игры → разверните карточку игры → секция «Рекламные сети» → Unity Ads
   Three fields:
   - Organization Core ID (Unity Dashboard → Organization Settings → Organization core ID)
   - Monetization Stats API Key (Unity Dashboard → Monetization → Setup → API Management
     → Monetization Stats API Access → Regenerate)
   - Game IDs (comma-separated Android+iOS project IDs from Unity Dashboard → Projects,
     e.g. `6077073,6077072`; WITHOUT this filter the API sums all games in the org)

b) The platform's nightly cron (01:15 MSK) pulls exact revenue in USD via Unity
   Monetization Stats API, converts to RUB via Central Bank rate, and distributes it
   across the SDK-recorded impressions for that day.

c) In the callback, pass revenue=0 — the call is only needed to LINK the impression to
   the specific player / influencer:

```csharp
public class AdController : MonoBehaviour, IUnityAdsShowListener {
    public void OnUnityAdsShowComplete(string placementId, UnityAdsShowCompletionState state) {
        if (state == UnityAdsShowCompletionState.COMPLETED) {
            // revenue=0 is INTENTIONAL — exact $ arrives nightly from Monetization Stats API
            GameDevPartnerSDK.TrackAdRevenue(0, "USD", "rewarded", "unity_ads", placementId);
        }
    }
    // ... OnUnityAdsShowStart / OnUnityAdsShowFailure / OnUnityAdsShowClick — nothing to add
}
```

### iOS vs Android may use different networks

If Android uses Yandex with mediation and iOS uses Unity Ads standard (or any other
combination), add `TrackAdRevenue` in EACH network's callback with the correct `adNetwork`
string. The SDK itself determines the platform; you just pass the right network name.

==================================================================
## Updating an existing install — check the pin first

Look at "Installed From" in the Package Manager (or the entry in `Packages/manifest.json`).

- If it ends with a tag (`...git#v2.9.0`), the package is PINNED. Unity's "Update" button does
  nothing in this case — the revision is fixed on purpose. To move to a newer version, add the
  git URL again with the new tag; the entry is overwritten and Remove is not needed:
  `https://gitlab.com/nonstop1899/gamedevpartner-unity-sdk.git#v2.20.1`
- If there is no tag, Package Manager may cache an old commit hash. Then do a full reset:
  Remove, close Unity, delete `Library/PackageCache/com.gamedevpartner.sdk@*`, reopen, add again.

Do not tell the developer their SDK is broken before checking this — a pinned tag looks exactly
like "updates are not arriving".

## Game analytics (since v2.8.0) — OPTIONAL but high value

IMPORTANT distinction, do not blur it:
- Sessions, app version, instability signals, and the device passport (model / GPU / OS / RAM,
  since 2.12.0) are sent AUTOMATICALLY once the ApiKey is set. Do NOT write wrappers, timers,
  or session bookkeeping for these — you would be duplicating the SDK and producing double
  counts. Do NOT collect SystemInfo yourself "for analytics" — it is already sent.
- Everything below is opt-in markup the game must call itself. Each call switches on one report
  in the cabinet. If the game does not call it, that report stays empty — it cannot be
  backfilled later, because events are not retroactive.

Use ONLY these method names. They exist exactly as written; do not invent variants,
do not add overloads, do not rename parameters.

```csharp
using System.Collections.Generic;
using GameDevPartner.SDK;

// ── Economy: where currency comes from and where it goes ─────────────────────
// source / sink are YOUR OWN strings for this game. The SDK imposes no vocabulary.
// balanceAfter is required: it is how a desync between grants/spends and the real
// balance is detected (unclosed sink, or cheating).
GameDevPartnerSDK.TrackCurrencyEarned("gold", 250, "quest_complete", balanceAfter: 1250);
GameDevPartnerSDK.TrackCurrencySpent("gold", 500, "hero_upgrade", balanceAfter: 750);

// ── Offers: the show → purchase funnel ──────────────────────────────────────
// context is the most valuable field: the cabinet can slice the funnel by ANY key in it.
// Put everything you know about the moment of the show (day of life, league, loss streak).
// context requires SDK 2.14.1+: earlier versions silently destroyed the Dictionary
// (serialized via ToString into "System.Collections.Generic.Dictionary`2[...]").
// The game code was always correct — just rebuild on 2.14.1, no code changes.
GameDevPartnerSDK.TrackOfferShown(
    offerId: "starter_pack", productId: "com.game.starter", price: 249, currency: "RUB",
    trigger: "after_3_losses",
    context: new Dictionary<string, object> { { "day_n", 2 }, { "league", "silver" } });
GameDevPartnerSDK.TrackOfferClicked("starter_pack");
GameDevPartnerSDK.TrackOfferPurchased("starter_pack", "com.game.starter", 249, transactionId);
GameDevPartnerSDK.TrackOfferDismissed("starter_pack");
GameDevPartnerSDK.TrackOfferExpired("starter_pack", seenCount: 4);

// ── Onboarding funnel: where new players drop off ───────────────────────────
GameDevPartnerSDK.TrackTutorialStep(stepN: 3, name: "first_battle");
GameDevPartnerSDK.TrackTutorialStep(stepN: 4, name: "choose_hero", skipped: true);

// ── Progression: kind is generic — account | hero | battlepass | league ─────
GameDevPartnerSDK.TrackProgression("account", level: 12, cost: 500);

// ── Level attempts with outcome (2.10.0): winrate, stars, win/loss duration,
//    drop-off to next. The win flag is what enables difficulty auto-tuning. ──
GameDevPartnerSDK.TrackLevelStart("5");
GameDevPartnerSDK.TrackLevelEnd("5", win: false, stars: 0, durationSec: 42);

// ── Ads: comparing placements against each other ───────────────────────────
GameDevPartnerSDK.TrackAdShown("main_menu", "rewarded", "admob");
GameDevPartnerSDK.TrackAdCompleted("main_menu", rewarded: true);

// ── Anything else ──────────────────────────────────────────────────────────
GameDevPartnerSDK.TrackEvent("boss_defeated", new Dictionary<string, object> {
    { "boss_id", "dragon" }, { "attempts", 7 }, { "hero_level", 12 },
});

// Rarely needed: events batch automatically and flush on quit.
GameDevPartnerSDK.FlushAnalytics();
```

Rules when wiring analytics:
- TrackCurrencyEarned/Spent must be called at the SINGLE place where the balance actually
  changes, not at every UI site that displays a reward. Otherwise you double-count.
- **Offer attribution — get these three right or the funnel lies:**
  - `TrackOfferPurchased` MUST carry the SAME `productId` as the shown event. The platform
    attributes a purchase to the last show of the same player and the same product — product_id
    is the attribution key. A purchase whose product never appeared in a show lands in an
    explicit "no show" bucket instead of some offer's zero.
  - `offerId` in the purchase must come from the SAME vocabulary as in the shows. Real bug
    from a live game: shows sent `PackForBeginner`/`MilitaryBag`, purchases sent the
    placement — `popup`/`store`. Two vocabularies in one field cannot be joined.
  - Call `TrackOfferClicked` when the player taps the offer — it is what enables the
    show → click → purchase funnel stage. Without it the click step reads zero.
  The dashboard then shows per-offer conversion (show → purchase of the same player+product)
  and the MEDIAN time from show to purchase — how long players think before buying.
- Do not send personal data (email, phone, real name) in event parameters or context.
- Do not send `install` or `version_update` yourself — the platform derives them server-side.
  A client-side install event is lost on reinstall and would corrupt cohorts.
- Do not build your own session tracking on top of the automatic one.
- Numbers must be culture-invariant. If you format values yourself, use InvariantCulture:
  a RU locale turns 10.5 into "10,5" and breaks both JSON and the request signature.

After wiring, tell the developer to open in Unity:
`Window → GameDevPartner → Доктор аналитики` — it reports which cabinet reports will work and
which will stay empty. Note honestly that the check is static (it greps for calls in the project),
so "call found" does not prove "event arrives"; the cabinet is the final source of truth.

## Player profile (since v2.16.1) — OPTIONAL, zero code

A cabinet the player opens **from inside the game**: achievements computed by the platform from
the game's own analytics, plus rewards for them that come back into the game. It renders in a
native WebView on top of the running game (Android Activity / iOS WKWebView, no third-party
plugins). In Editor and on desktop it falls back to the system browser.

Prerequisite the game cannot fix itself: achievements must be switched on for this game on the
platform side. Until then the cabinet opens and is empty.

Each player's count starts from THEIR OWN first contact with the hub (a personal anchor row),
not from a single game-wide date — store review times and staged rollout make one impossible.
Never retroactively. If it looks empty, say so instead of debugging the game.

### Preferred wiring — no code at all

Achievements come as LADDERS, not single items: a line of 4-8 steps ("levels: 3 / 6 / 10 /
20 / 50 / 100 / 200 / 500"), only the next step lit. Ladder length is set by TIME, not by a
step count — we measure the typical player's pace in that game and extend until the top step
is roughly a year-long goal. The top step pays a TITLE that lives in the cabinet, not currency:
the game grants nothing and handles nothing for it (no reward record is even created). This
protects the game's economy. Nothing to integrate — this is server-side.

The cabinet shows a rarity tier and share per achievement ("Очень редкое, 12%") — measured
from the game's own telemetry, switching to observed cabinet unlocks once there are enough
holders. Nothing about other games, no player counts, no revenue. Shares above 85% are hidden
(that is not rarity, it is noise). Nothing to integrate — this is server-side.

Add the component `GDPProfileButton` to the game's OWN profile button (the developer's art and
layout; the SDK does not create or restyle any UI):

- the click is wired automatically in `Awake` — do NOT also add `Open()` to `Button.OnClick()`;
- `Badge Root` — any GameObject; SDK shows it while rewards are waiting and hides it otherwise;
- `On Badge Text` — drag the label (for TextMeshPro pick the `SetText(string)` method);
- `On Currency Reward` — drag the game's own `AddCurrency(string currency, int amount)`.

**If you add the component from code, deactivate the object first.** Unity runs `Awake`/`OnEnable`
**synchronously inside `AddComponent`**, before your next line. Setting the serialized fields
afterwards is too late for the first `OnEnable`, and the guard logs
«Кабинет: у кнопки не заполнено On Currency Reward» even though the setup is correct:

```csharp
bool wasActive = go.activeSelf;
go.SetActive(false);                       // Awake/OnEnable deferred
var hub = go.AddComponent<GDPProfileButton>();
// …assign deliverRewardsWhenShown / badgeRoot / onCurrencyReward here…
go.SetActive(wasActive);                   // wakes with your values already set
```

Rewards are delivered correctly the whole time — the message is noise, but expensive noise:
reported from a live game as 4 670 errors on 1 585 devices (one per button creation), which made
it the top crash-reporter signature and hid the real ones. Inspector setup is unaffected.

### Code wiring, when the game grants rewards through its own systems

```csharp
GameDevPartnerSDK.Profile.Open();          // open the cabinet (e.g. from a button)
GameDevPartnerSDK.Profile.PendingCount;    // badge number, 0 means hide the badge
GameDevPartnerSDK.Profile.Accent = "#ff5588";  // optional, makes the cabinet look like the game
GameDevPartnerSDK.Profile.Language = "en";     // set if the game has a language switch:
                                               // the cabinet runs in a WebView and otherwise
                                               // sees the language of the PHONE, not the game

GameDevPartnerSDK.Profile.OnRewardsReady += codes => {
    foreach (var code in codes)                                   // "hearts_25", "gold_100"
        foreach (var g in GameDevPartnerSDK.Profile.ParseReward(code))
            AddCurrency(g.Currency, g.Amount);                    // ("hearts", 25)
};
```

Reward codes are `<currency>_<amount>`, possibly compound: `hearts_40_ticket_1` parses into
two grants. Do NOT write your own parser and do NOT hardcode the reward list — the catalogue lives
on the server and changes without a game rebuild.

**Since 2.16.3 the reward also carries WHAT the player did.** Currency is what they got; the
title is what they achieved. Without the second, the grant moment reads as a bank statement.

```csharp
GameDevPartnerSDK.Profile.OnRewardsReadyDetailed += rewards => {
    foreach (var r in rewards) {
        foreach (var g in GameDevPartnerSDK.Profile.ParseReward(r.RewardId))
            AddCurrency(g.Currency, g.Amount);
        ShowToast(r.Title, r.Hint);                    // "Your own shelf" · "68 hearts"
        if (r.TitleText != null) ShowTitle(r.TitleText);
        if (r.HasRarity) ShowRarity(r.RarityShare);    // check the FLAG, not RarityShare != 0
    }
};
```

`r.Title` is the localized title YOU wrote through the texts API — no second request needed.
Already wired to `OnRewardsReady`? Keep it and look the title up:
`GameDevPartnerSDK.Profile.GetRewardInfo(code)`.

🔴 Subscribe to ONE of the two events. Both fire on the same batch — a double subscription
grants twice.

🔴 `HasRarity` is a separate flag on purpose: `JsonUtility` cannot express `null` for numbers,
so "not measured" and "nobody reached it" would both arrive as `0`. Those are different claims.

`Title` is `null` when the step was retired from the catalogue — show the reward without a name
rather than hiding it. The player earned it.

🔴 Wrap the PRESENTATION (toast, sound, counters) in its own try/catch, separate from the grant.
An exception in your handler means "not delivered" to us, and we will offer the same reward
again — while the currency is already in the player's wallet.

### Delivery timing — the game decides, not the SDK

```csharp
GameDevPartnerSDK.Profile.DeliverPending();   // call from where wallet + UI + save are alive
```

The SDK does **not** deliver rewards at startup. Granting is impossible that early in most games
(the wallet hangs off the HUD and the save system), the handler would throw, and because a throw
means "not delivered" the same reward would return at the same early moment next session —
forever. That is a deadlock, not a retry. `GDPProfileButton` calls DeliverPending() itself when
the button appears on screen; uncheck `Deliver Rewards When Shown` if you have your own safe
moment. `Refresh()` only updates the badge and delivers nothing.

### The one rule you must not break

Delivery is confirmed to the server **only if the handler returns without an exception**. So:

- let exceptions propagate if the grant genuinely failed — the same reward is offered again;
- do NOT swallow errors in a `try/catch` that returns normally: that confirms a delivery that
  never happened, and the reward is gone for good;
- do NOT keep your own "already granted" list — the SDK persists one in PlayerPrefs, so a network
  drop between "granted" and "confirmed" cannot double-grant.

Nothing else is needed: no polling, no lifecycle hooks. The SDK refreshes on startup, on resume
and when the cabinet closes.

### Link reward — starter bonus for players who install via a creator's link (since v2.19.0)

A player who installs through a creator's tracking link receives in-game currency on first
launch. No promo codes, no pop-ups, nothing to type. The reward arrives through the **same
channel as cabinet rewards** — `(currency, amount)` → the game's `AddCurrency`.

- **If `GDPProfileButton` with `On Currency Reward` is already wired: do NOTHING.** Link rewards
  come through it. Do not add a second handler.
- **If the game has no cabinet button:** add `GDPRewardReceiver` (Add Component → GameDevPartner →
  Reward Receiver) to an object that exists once the wallet is loaded (main menu, not splash), and
  drag `AddCurrency(string currency, int amount)` into `On Currency Reward`. That is the whole
  integration. From code (SDK 2.20+): `gameObject.AddComponent<GDPRewardReceiver>().SetHandler(AddCurrency);`
  — `onCurrencyReward.AddListener` is NOT seen by the component, use SetHandler.
- **Never add both** a delivering profile button and a receiver — two subscribers would grant the
  same batch twice. The receiver detects the button and steps back with a warning, but don't rely
  on it: pick one.
- Do NOT implement promo-code entry, do NOT build your own "reward on first launch" — the server
  decides who gets the bonus (new installs attributed to a link, after anti-fraud, once per
  player per game) and how much (capped by the developer's limit). The developer configures
  currency and cap in the cabinet; the currency codes come from the game's own
  `TrackCurrencyEarned/Spent` events, so those must be instrumented first.
- The creator is NOT paid for the bonus — it goes to the player. Do not describe it as creator
  compensation.

## Player identity — decide this BEFORE the first release

Everything the platform computes — purchases, attribution, retention, achievements — hangs off
the player id. Get it wrong and one human shows up as two.

**Default (zero code):** the SDK identifies the player by a device identifier. Fine to start with,
but on iOS that identifier CHANGES after a reinstall when no other app of the same vendor is left.
That player becomes a brand-new player and loses all history. On Android it survives reinstalls.

**If the game has its own stable id** (cloud save, account) — name it:

```csharp
GameDevPartnerSDK.IdentifyPlayer("your_stable_id");
```

History is NOT lost: the SDK attaches the id it used before, and the platform links them and moves
purchases, attribution, subscriptions and achievements over. Sent once, on the launch where the id
changes. The game does not need to know anything about migration.

🔴 **Do it before release.** The stitch happens while the SDK can still see both sides. If the player
reinstalls before you migrate, there is nothing left to link.

**Restoring an OLD id** (the player restores progress from a cloud save / logs into the old account, and the
game gives them their previous id back): call `GameDevPartnerSDK.ResetPlayerId()`, then
`IdentifyPlayer(previousId)` — same session, no restart. The next identification then carries no
"previous id" and links nothing to the throwaway id. Do NOT delete `gdp_player_id` from PlayerPrefs by
hand: since SDK 2.20.0 the SDK keeps it in a file too (that is how it survives `PlayerPrefs.DeleteAll()`)
and it comes back. `ResetPlayerId()` needs SDK 2.20.1+.

**Server-side webhooks do NOT need to be switched.** Every ingest point — events, purchases, cabinet
calls — accepts ANY known identifier and resolves it to the canonical player. Your backend can keep
sending the old id for years. Do not orchestrate a simultaneous client+server migration; it is not needed.

If your backend learns the pairing before the client does (store renewal webhooks usually carry both
identifiers), link it server-side:

```
POST /api/v1/identity/link      Authorization: Bearer <game API key>
{"player_id":"<stable>", "alias_value":"<old>", "alias_type":"device"}
```

**One device, two players** (family tablet) is NOT merged: a device id is a weak alias — it can join
an identity but never fuse two of them. The platform records the conflict and leaves both players alone.

## Reading analytics back out (REST) — for dashboards, BI and AI assistants

Every cabinet report is available over REST. Auth: developer token (starts with `gdp_`),
issued in the dashboard. This is the same token used for release annotations.

**Start with capabilities, never guess which reports have data:**

```
GET /api/v1/dev-api/games                                     -> your games, take gameId from here
GET /api/v1/dev-api/analytics/capabilities?gameId=<id>        -> which reports are filled, and what
                                                                 to call in-game to fill the empty ones
GET /api/v1/dev-api/analytics/<report>?gameId=<id>&from=&to=  -> the report itself
```

**Player-hub achievements — YOU write the texts, the platform only measures.**

```
GET    /api/v1/dev-api/player-hub/achievements?gameId=<id>&locale=ru
       -> per achievement: code, base title/description, threshold, ruleType,
          measured rarityShare, horizonDays, reward, plus a "fields" glossary
PUT    /api/v1/dev-api/player-hub/achievements/<code>/text
       {"locale":"ru","title":"...","description":"..."}
DELETE /api/v1/dev-api/player-hub/achievements/<code>/text?locale=ru
PATCH  /api/v1/dev-api/player-hub/achievements/<code>/state
       {"isActive": false}          -- hide the step entirely
       {"rewardMuted": true}        -- SHOW it, but pay no currency (your injection budget)
       {"titleText": "Keeper"}      -- rename the title; applies FORWARD only
       {"familyTitle": "Supply"}    -- rename the LINE header, all its steps at once
       {"familyTitleText": "Keeper"}      -- name of the LINE's CROWN title (rare capstone)
       {"familyEntryTitleText": "Rookie"} -- name of the LINE's ENTRY title (reachable one)

GET    /api/v1/player-hub/player/<player_id>/titles     -- titles to show INSIDE the game
       Authorization: Bearer <game key>, player id from IdentifyPlayer
       -> {"nickname": "...", "equipped_title": "...", "titles": [...]}
       -> retire an achievement or bring it back, scriptable (not only in the web cabinet)
```

Same automation token as analytics (it carries `player-hub:write` alongside `analytics:read`).

Each step in the GET carries `metricSource: {event, field}` — WHICH of your events feeds the
line. Read it before writing texts: "spend" may be in-game gold or real money, "levels" may be
character level or waves cleared, and guessing from threshold values is how two projects wrote
texts about the wrong metric.

🔴 NEVER parse `rewardHint` to learn state. It is a string for the player's eyes, derived
from other fields and written at seed time. The Love Legend developer spent three days
searching it for the word "Title" to find which step carries one, on top of a field that was
lying: seven steps out of eight promised a title they did not have. Read state from
`titleRank` (crown / entry / null), `rewardId` and `rewardMuted`. General rule, and it is
worth applying beyond us: **a derived display string is never a source of truth about state.**

A title name belongs to the LINE, not to a step. Which step CARRIES the title is decided by
measured rarity and moves as your audience grows; the name does not move with it. That is why
you set it with `familyTitleText` (and `familyEntryTitleText` for the reachable one) rather
than per step — the old per-step field `titleText` still works but is a one-off write that
rarity recalculation will drift away from.

Since 2.16.6 the game can read the player's self-chosen identity WITHOUT a backend and
without an extra request — it rides along with the badge the SDK already polls:

    GameDevPartnerSDK.Profile.Nickname   // null until he names himself AND allows showing
    GameDevPartnerSDK.Profile.Title      // the title he chose to wear
    GameDevPartnerSDK.Profile.OnIdentityChanged += (nick, title) => { ... }

The event fires after he returns from the cabinet: he may have renamed himself there, and
your screen should pick that up rather than show the old value until restart.

Since 2.16.7 the button no longer logs an error when «On Currency Reward» is empty AND
deliverRewardsWhenShown = false — that combination is a deliberate opt-out, not a mistake:
filling the field would subscribe the button on top of your own handler and grant one batch
TWICE. It logs one informational line per launch instead.

The player now has a PROFILE in the cabinet: a name he types himself, an avatar (12 emblems,
or any icon he has already unlocked), a three-slot showcase, and the title he chose to wear.
Nothing of this is required from you. One thing is worth doing: the titles endpoint returns
`nickname` and `equipped_title` — but ONLY when the player explicitly allowed showing them.
Both are `null` otherwise, and that is not an error: the cabinet is the only place he can
see his own status, so showing it next to his name in YOUR game is what makes status status.
Do not invent your own storage for a nickname; the player already has one.

Two day lines exist and they are NOT the same thing: `days` is a STREAK (consecutive days,
a miss resets it, capped at 60) and `days_total` counts how many DIFFERENT days the player
was in the game (a miss costs nothing, reaches 3650). A year IN the game is achievable;
a year IN A ROW is not, and measuring both with one line was our design error.

Your decisions survive catalogue rebuilds: muted payouts, title names and the texts you wrote
are inherited by POSITION in the line, not by step code — the code contains the threshold, and
thresholds get recalibrated.
Send `Content-Type: application/json; charset=utf-8` and write the body as UTF-8 BYTES — on
Windows shells `curl --data` mangles Cyrillic and the API faithfully stores the mangled text. Idempotent per (code, locale) — safe from CI. Renaming does
NOT touch player progress. Generated titles are machine drafts («Пройдено уровней: 20») —
the platform does not know the game's vocabulary, you do. Rewrite them before players see them.

🔴 Only wording goes through this API. Thresholds, horizon, rarity and reward size are
measured by the platform from cross-game distributions the game cannot see; an extra field
in the body is an explicit error, not a silent drop. Need a different threshold — rebuild
the catalogue at /developer/player-hub, which recomputes share, horizon and reward together.

Reports: `overview`, `retention` (supports `country`), `stability`, `by-device`, `money`, `by-store`, `economy`, `vocabulary`, `funnel`, `progression`, `offers`, `ads`, `geo`, `events-explorer`, `release-impact`.

`finance-daily`: money by Moscow day, Russia and world split (RUB payments vs other currencies) — gross,
refunds, commission, net by payment method, ad revenue by network, user-acquisition spend, DAU / new / payers.
`&excludeSource=web` drops a payment method from the sums when that money is already counted elsewhere.

`events-explorer`: repeat `where` for AND conditions (up to 5): `&where=battle_n:7&where=predicted_winner:Enemy`;
`&limit=1..500` rows in byParam (default 30). Event reports skip fraud-blocked installs and test devices
(3+ offer purchases with no payment ever) — `&includeBlocked=1` brings them back.
`release-impact` mode A counts D1/D3/D7 only for players whose day N has fully passed; the real n is in
each metric (`nBase`/`nVariant`).

🔴 **Freeze metric breaks at SDK 2.16.1.** Before it, returning from background was counted as a freeze:
minimising the game for a minute produced a multi-second "hang". Measured across five live games the
median fake freeze was 1.9–5.2s with thousands over 10s. From 2.16.1 only real unresponsiveness counts.
**Do not compare freeze numbers across that boundary** — release-impact will show a dramatic improvement
the build did not make.

## Reporting releases back to the platform (for CI and AI assistants)

If the developer gives you a platform token (starts with `gdp_`), report what changed in each
release. The note shows up next to the version-comparison metrics in their dashboard, so a month
later they can still tell WHY a metric moved.

```bash
curl -X POST https://gamedevpartner.ru/api/v1/dev-api/releases   -H "Authorization: Bearer gdp_TOKEN"   -H "Content-Type: application/json"   -d '{"gameId":"UUID","version":"1.4.0","note":"raised starter pack price 199 -> 249"}'
```

Rules:
- The token is NOT the SDK ApiKey and the two are never interchangeable. The ApiKey ships inside
  the built game; anyone who unpacks the APK has it. Never send the ApiKey to this endpoint, and
  never put the platform token into the game build or into `GameDevPartnerConfig`.
- Idempotent per (game, version): calling it again updates the note instead of creating duplicates.
  Safe to run on every CI build.
- Omit `releasedAt` unless you are backfilling — it defaults to the moment of the call.
- Get `gameId` from `GET /api/v1/dev-api/games` with the same token. Do not guess it.
- Write what a human changed in product terms ("raised price", "rewrote tutorial step 2"), not the
  git shortlog. The note exists to explain metric movements, not to duplicate the commit history.

## Gradle (Android) — AUTOMATIC since v2.7.4, do not add manually
==================================================================

Since v2.7.4 the SDK injects `com.android.installreferrer:installreferrer:2.2` into the
generated gradle project on every Android build automatically (Editor postprocessor;
idempotent — no duplicate if an MMP like AppsFlyer/Adjust already brings it transitively).
Do NOT add this dependency manually and do NOT create gradle templates for it.

Only if the project is pinned to SDK < 2.7.4, add manually to
`Plugins/Android/mainTemplate.gradle`:

```gradle
dependencies {
    implementation 'com.android.installreferrer:installreferrer:2.2'
}
```

==================================================================
## Paid-traffic attribution (Yandex Direct / Unity Ads) — ZERO game code
==================================================================

The platform also attributes PAID installs (Yandex Direct tracking links, Unity Ads
campaigns) and shows them in the developer dashboard. This needs NO integration work
beyond the standard setup above: the SDK already reads the install referrer (yclid/utm)
and sends the device advertising ID (GAID); click-matching and Unity install postbacks
happen server-side. Do NOT add any code or callbacks for this, and do NOT install
AppMetrica/AppsFlyer "for attribution" — it is built in. The developer configures
campaigns in the dashboard: Продвижение → Реклама.

==================================================================
## Hard NO-GO list
==================================================================

- Do NOT call `IdentifyPlayer()` with a device id or a made-up id — Auto Identify does that. Calling it
  with the game's own stable id (account, cloud save) is correct and expected — see «Player identity»
- Do NOT track the same purchase twice — SDK already deduplicates by `TransactionId`
- Do NOT ship with the test API key in production builds
- Do NOT include store commission in `Amount` — use the gross price the player paid
- Do NOT invent revenue values for Unity Ads standard — pass 0 and connect the dashboard
- Do NOT write `new AdRevenueEvent { ... }` — no such class exists; arguments are POSITIONAL
- Do NOT write `AdNetwork.Something` enum — `adNetwork` is a plain string from the list above
- Do NOT reference `ImpressionEventPublisher` / `data.revenue` for Unity Ads standard SDK —
  these do NOT exist in that SDK (they exist only in LevelPlay, which is separate)

==================================================================
## Integration checklist
==================================================================

- [ ] Unity Package Manager installs the SDK from the git URL without errors
- [ ] Window → GameDevPartner → Settings appears; API key saved
- [ ] Purchases tracked (either `GDPUnityIAP.Initialize` or manual `TrackPurchase`)
- [ ] Every ad impression callback calls `TrackAdRevenue` with correct `adNetwork` string
- [ ] Unity Ads standard: `revenue=0` passed AND dashboard has Core ID + Stats API Key + Game IDs
- [ ] Android: NO manual gradle edits for installreferrer (SDK >= 2.7.4 injects it; add manually only on older SDK)
- [ ] Test build: a test purchase and an ad impression appear in
      gamedevpartner.ru/developer/analytics within a minute
- [ ] Player profile (if used): GDPProfileButton on the game's own button, reward handler does NOT
      swallow exceptions, no hardcoded reward list
- [ ] Player identity decided: if the game has a stable id, IdentifyPlayer(it) is called — BEFORE release
- [ ] Debug Mode turned OFF in the production build