flowchart LR
subgraph Device["Player device"]
G["Your game client"]
S["Baziness sheet<br/>iframe → baziness.com"]
P["Payment tab<br/>pay.baziness.com/buy"]
end
subgraph Backend["Your backend"]
B["Game server<br/>(holds clientId + clientSecret)"]
end
K[("Baziness platform API")]
Y[("Baziness Pay API")]
G -- "/api/platform/*" --> B
G -- "/api/shop/purchase/*" --> B
B -- "client/auth · create-user · add-coin …" --> K
B -- "get_payment · verify_payment · consume" --> Y
G -- "user/get-coin (user token)" --> K
G -- "opens" --> S
S -- "postMessage: login · logout · close" --> G
G -- "opens" --> P
کلاینت فقط در دو مورد مستقیم با بازینس حرف میزند: صفحههای بازینس را نشان میدهد، و موجودی سکه را با توکن خود بازیکن میخواند (user/get-coin). همهی کارهای دیگر از سرور شما میگذرند، چون فقط سرور شما کلید محرمانه را دارد.
تیم پلتفرم بازینس این مقدارها را به شما میدهد، برای هر محیط یک دسته (توسعه و اصلی):
مقدار
مثال
چه کسی استفاده میکند
محرمانه؟
شناسهی کلاینت (client id)
12
سرور شما، برای گرفتن توکن کلاینت
نه، ولی خصوصی نگهش دارید
کلید محرمانه (client secret)
••••••••
سرور شما، برای گرفتن توکن کلاینت
بله، فقط سرور
نام بستهی پلتفرم
com.karizmastudio.example
لینک همهی صفحههای بازینس (packageName)
نه
نام بازی
example
صفحهی جایزه و صفحهی پیشنهاد (game_name)
نه
نام بستهی پرداخت
com.karizmastudio.example
همهی درخواستهای پرداخت (pname)؛ ممکن است با نام بستهی پلتفرم فرق کند
نه
SKUها
example_pack
محصولاتی که با پرداخت بازینس میفروشید
نه
هشدار
کلید محرمانه هیچوقت نباید به مرورگر یا بیلد اپ برسد: نه در کد کلاینت، نه در متغیرهای VITE_* یا NEXT_PUBLIC_*، نه در گیت. هر کسی که آن را داشته باشد میتواند به نام بازی شما بازیکن بسازد و گیتا کوین بدهد.
بازی نمونه و نمونه کدها این تنظیمات را میخوانند. همین نامها را نگه دارید تا نمونهها بدون تغییر کار کنند:
تنظیم
مثال
معنی
BAZINESS_PLATFORM_ENABLED
true
کلید اصلی. تا وقتی false است بازی هیچ بخش بازینسی نشان نمیدهد
BAZINESS_PLATFORM_USE_PRODUCTION
false
false = آدرسهای توسعه، true = آدرسهای اصلی
BAZINESS_CLIENT_ID
12
شناسهی کلاینت
BAZINESS_CLIENT_SECRET
••••
کلید محرمانه (محرمانه)
BAZINESS_PLATFORM_PACKAGE_NAME
com.karizmastudio.example
packageName در لینک صفحهها
BAZINESS_GAME_NAME
example
game_name در صفحهی جایزه و پیشنهاد
BAZINESS_PAY_ENABLED
true
دکمههای خرید را روشن میکند
BAZINESS_PAY_BASE_URL
https://pay-dev.karizmastudios.org
سرور پرداخت این محیط
BAZINESS_PAY_PACKAGE_NAME
com.karizmastudio.example
pname در همهی درخواستهای پرداخت
BAZINESS_SKU_PREFIX
(خالی)
اختیاری: SKU = پیشوند + شناسهی محصول
پلتفرم فقط وقتی روشن حساب میشود که BAZINESS_PLATFORM_ENABLED=true باشد و هم شناسه و هم کلید محرمانه تنظیم شده باشند. به این ترتیب سروری که نیمهکاره تنظیم شده، دقیقاً مثل وقتی رفتار میکند که بازینس اصلاً وجود ندارد.
API
API پلتفرم، قدمبهقدم
همهی endpointهای پلتفرم به ترتیبی که استفادهشان میکنید: چه کاری میکند، کِی صدایش بزنید، چه بفرستید و چه برمیگردد. بدون کد؛ کد آماده در بخش «نمونه کد» است.
توکنهای کاربر در حال تغییر به توکن دسترسی ۱۵ دقیقهای، توکن تمدید روی سرور شما و کد اتصال یکبارمصرف هستند. قدمهای زیر در دورهی مهاجرت همچنان کار میکنند. چه چیزی را تا کی عوض کنید: مهاجرت توکن.
بدنهی درخواست و پاسخ JSON است (Content-Type: application/json) و نام فیلدها camelCase
چه کسی صدا میزند
سرور شما، به جز قدم ۶ (user/get-coin) که کلاینت بازیکن صدا میزند
احراز هویت
قدمهای ۲ تا ۵ هدر Authorization: Bearer <توکن کلاینت> از قدم ۱ را میخواهند؛ قدم ۶ توکن کاربر بازیکن را
کل ترتیب برای یک بازیکن جدید:
sequenceDiagram
autonumber
participant S as Your server
participant K as Platform API
participant C as Player's client
S->>K: client/auth (client id + secret)
K-->>S: client token
S->>K: client/create-user (device id)
K-->>S: userId + user token
S-->>C: user token
C->>K: user/get-coin (user token)
K-->>C: balance
Note over C: the player signs in on a Baziness page → the page gives the client a token
C->>S: that token
S->>K: client/validate-user-token
K-->>S: userId (who signed in)
S->>K: client/add-coin (when the player wins)
K-->>S: new balance
چه کار میکند:شناسهی کلاینت و کلید محرمانه را با یک توکن کلاینت عوض میکند. همهی درخواستهای دیگر سرور (قدمهای ۲ تا ۵) این توکن را در هدر Authorization میفرستند.
کِی صدایش بزنید: با اولین درخواست به پلتفرم بعد از بالا آمدن سرور، و دوباره هر وقت درخواستی پاسخ 401 داد، چون توکن منقضی شده است.
HTTP
POST {api}/client/auth
Content-Type: application/json
{ "clientId": 12, "clientSecret": "••••••••••••" }
→ 200 OK
{ "clientId": 12, "token": "eyJhbGciOiJIUzI1NiIs…" }
قانونها
توکن را در حافظهی سرور نگه دارید و برای همهی درخواستها از همان استفاده کنید؛ برای هر درخواست توکن تازه نگیرید.
اگر درخواستی پاسخ 401 Unauthorized داد، دوباره client/auth را صدا بزنید و همان درخواست را یک بار دیگر بفرستید.
توکن کلاینت به اندازهی کلید محرمانه حساس است: هرگز آن را به کلاینت نفرستید.
چه کار میکند: برای یکی از بازیکنان شما یک کاربر بازینس میسازد و اینها را برمیگرداند:
userId بازینس: آن را روی رکورد بازیکن ذخیره کنید؛ برای همهی درخواستهای بعدی لازم است
یک توکن کاربر برای این بازیکن: آن را به کلاینت بفرستید
isLinkedToAccount: آیا این کاربر با شماره موبایل وارد شده (true) یا هنوز ناشناس است (false)
کِی صدایش بزنید: اولین باری که بازیکن شما به بازینس نیاز دارد؛ معمولاً اولین اجرای بازی وقتی پلتفرم روشن است. اگر از قبل userId بازیکن را دارید، به جای این قدم ۳ را صدا بزنید.
شناسهی ثابت بازیکن شما، مثلاً شناسهی دستگاهی که بازی در اولین اجرا ساخته
platform
Web، Android، Ios یا Editor
store
Direct (وب)، GooglePlay، AppStore، CafeBazaar یا Myket
linkedToken
توکن کاربری که بازیکن از قبل دارد، یا null. اپ بازینس بازیها را با ?linkedToken=… باز میکند؛ فرستادن آن در اینجا بازیکن را به همان حساب بازینس وصل میکند
مهم
اگر userId برگشتی از قبل روی بازیکن دیگری از بازیکنان شما ذخیره شده، این همان شخص است، مثلاً روی گوشی جدید. آن را دوباره ذخیره نکنید؛ به جایش این دستگاه را به آن بازیکن جابهجا کنید (ورود ← جابهجایی حساب). روی ستونی که userId را نگه میدارد محدودیت یکتا بگذارید.
چه کار میکند: برای کاربر بازینسی که از قبل میشناسید یک توکن تازه و مقدار فعلی isLinkedToAccount را برمیگرداند.
کِی صدایش بزنید: در هر جلسهی بعدی بازیکن، معمولاً هر بار که کلاینت بالا میآید. به این ترتیب کلاینت همیشه توکن معتبر دارد، و شما میفهمید کِی یک کاربر ناشناس وارد حسابش شده (isLinkedToAccount شده true).
HTTP
GET {api}/client/get-user-token/239236
Authorization: Bearer <client token>
→ 200 OK
{ "userId": 239236, "token": "eyJhbGciOiJIUzI1NiIs…", "isLinkedToAccount": true }
چه کار میکند: میگوید یک توکن کاربر واقعی است یا نه، و مال کدام کاربر بازینس است.
کِی صدایش بزنید: هر وقت توکنی از کلاینت رسید و میخواهید بر اساسش کاری بکنید. مورد اصلی: بازیکن در صفحهی ورود بازینس وارد میشود، صفحه یک توکن به کلاینت میدهد و کلاینت آن را به سرور میفرستد. هیچوقت بر اساس توکنی که بررسی نکردهاید حسابی وصل نکنید یا چیزی ندهید.
HTTP
GET {api}/client/validate-user-token/eyJhbGciOiJIUzI1NiIs…
Authorization: Bearer <client token>
→ 200 OK
{ "userId": 239236, "isValid": true }
→ 200 OK (an invalid or expired token is NOT an HTTP error)
{ "userId": 0, "isValid": false }
هشدار
توکن نامعتبر خطای HTTP نیست: پاسخ 200 با "isValid": false و "userId": 0 است. همیشه isValid را بررسی کنید.
یک پرداخت همیشه یک شناسهی ثابت میسازد. تلاش دوباره، دو بار زدن دکمه یا درخواست تکراری با 400 رد میشود و هیچوقت دو بار پرداخت نمیشود.
این 400 را به معنی «قبلاً پرداخت شده» بگیرید و حسابداری خودتان را تمام کنید، مثلاً جایزه را دریافتشده علامت بزنید. وگرنه کرشی بین واریز و ذخیرهی شما بازیکن را پشت یک خطا گیر میاندازد.
شناسهای که از ساعت ساخته شود (<userId>_<amount>_<time>) با هر بار صدا زدن دوباره واریز میکند؛ فقط برای پرداختهایی که واقعاً تکرارشوندهاند.
چه کار میکند: موجودی گیتا کوین بازیکن را برمیگرداند. تنها درخواست پلتفرم است که کلاینت با توکن کاربر بازیکن میزند. درخواست از دامنههای دیگر مجاز است، پس بازی مرورگری میتواند مستقیم صدایش بزند.
کِی صدایش بزنید: بعد از اینکه کلاینت توکن گرفت، وقتی یک صفحهی بازینس بسته میشود (شاید بازیکن سکه خرج کرده باشد)، وقتی بازی دوباره به جلو میآید، و بعد از اینکه سرور سکه داد.
HTTP
GET {api}/user/get-coin
Authorization: Bearer <user token>
→ 200 OK
{ "resourceId": 1, "amount": 2450 }
بازی را بدون بخشهای بازینس ادامه دهید و بعداً دوباره تلاش کنید
API
صفحههای بازینس و پیامهایشان
صفحههای وبی که بازی شما داخل خودش نشان میدهد (ورود، پروفایل، فروشگاه جایزه و صفحهی پیشنهاد بازی)، پارامترهایی که هر کدام لازم دارد و پیامهایی که برمیگردانند.
بعضی بخشهای بازینس صفحهی وب هستند که بازی شما داخل خودش نشان میدهد؛ مثلاً صفحهی ورود و فروشگاه جایزهی «انتخاب جایزه». آدرس صفحه را در یک وبویوی داخل بازی باز میکنید: روی وب یک iframe، در موتور بازی یک کامپوننت WebView. صفحه با پیام با بازی حرف میزند، مثلاً «بازیکن وارد شد» یا «مرا ببند».
همهی صفحهها روی یک سایت هستند:
محیط
آدرس پایهی صفحهها {web}
توسعه
https://platform-web-dev.karizmastudios.org
اصلی
https://baziness.com
ساختن نمای داخل بازی (شیتی که از پایین بالا میآید و با کشیدن به پایین بسته میشود) در شیت پایین / وبویو آمده است.
linkCode (کد یکبارمصرف از سرور شما با v2/client/link-code/{userId}) جای linkedToken را میگیرد. در دورهی مهاجرت صفحهها هر دو را قبول میکنند. مهاجرت توکن را ببینید.
پارامتر
کِی
مقدار
چرا
packageName
همیشه
نام بستهی پلتفرم شما
بازی شما را معرفی میکند
sdkType
همیشه
js
به صفحه میگوید داخل بازی است تا برایتان پیام بفرستد. بدون آن حتی دکمهی بستن خود صفحه هم نمای شما را نمیبندد
linkedToken
هر وقت دارید
توکن کاربر بازیکن
صفحه به نام همین بازیکن کار میکند و ورود داخل صفحه به او وصل میشود
redirect
پروفایل، جایزه
profile / shop
صفحه بعد از ورود داخلش به کجا برگردد
allowGuest
ورود، پروفایل، پیشنهاد بازی
false
«ادامه به عنوان مهمان» خود صفحه را پنهان میکند؛ بازی شما این گزینه را دارد
game_name
جایزه، پیشنهاد بازی
نام بازی شما، URL-encode شده
جایزهها و پیشنهاد بازی شما را انتخاب میکند
buildStore
فقط بیلدهای فروشگاهی
CafeBazaar / Myket
صفحه لینک فروشگاههای دیگر را پنهان میکند
همهی مقدارها را encode کنید، چون game_name ممکن است فارسی باشد و توکنها ممکن است + یا / داشته باشند.
Login https://baziness.com/?packageName=com.karizmastudio.example&sdkType=js&linkedToken=eyJ…&allowGuest=false
Profile https://baziness.com/profile?packageName=com.karizmastudio.example&sdkType=js&linkedToken=eyJ…&redirect=profile&allowGuest=false
Rewards https://baziness.com/shop?game_name=example&packageName=com.karizmastudio.example&sdkType=js&linkedToken=eyJ…&redirect=shop
Game offer https://baziness.com/game-offer?game_name=example&packageName=com.karizmastudio.example&sdkType=js&linkedToken=eyJ…&allowGuest=false
مهم
برای فروشگاه جایزه /shop را باز کنید، نه ریشهی سایت (/). ریشه بازیکنی را که داخل بازی است به صفحهی ورود میبرد؛ /shop از توکنی که بازیکن دارد استفاده میکند.
صفحه با postMessage پیام میفرستد. هر پیام JSON است، به شکل رشته یا شیء، با قالب { "type": "...", "data": ... }:
type
data
معنی
کار بازی شما
close
—
دکمهی بستن صفحه زده شد
نما را ببندید
login
توکن: "<token>" یا { "token": "<token>" }
بازیکن وارد یک حساب شد
توکن را به سرور خودتان بفرستید تا بررسیاش کند و حساب را وصل کند یا به حساب دیگر بازیکن جابهجا شود؛ بعد توکن را تازه کنید و نما را ببندید
login
(بدون داده)
حساب فعلی بازیکن در همان جا وصل شد
بازیکن را متصل علامت بزنید و توکن را تازه کنید
token
توکن
دستدادن بیصدایی که صفحه هنگام بارگذاری میفرستد
نادیده بگیرید؛ ورود نیست
logout
—
بازیکن داخل صفحه خارج شد
نما را ببندید، هر چه بازی بهصورت محلی ذخیره کرده پاک کنید و مثل یک بازدیدکنندهی کاملاً جدید از اول شروع کنید
هشدار
فقط پیامهایی را بپذیرید که origin آنها صفحهای است که باز کردهاید ({web}) و بقیه را نادیده بگیرید.
هیچوقت token را ورود حساب نکنید؛ وگرنه فروشگاه جایزه به محض باز شدن بسته میشود.
API
پرداخت بازینس، قدمبهقدم
فروش آیتم با پول واقعی، به ترتیب: صفحهی پرداخت را باز کنید، پرداخت را پیدا و تأیید کنید، آیتم را دقیقاً یک بار بدهید و خرید را مصرف کنید تا دوباره قابل خرید باشد.
سرور شما. کلاینت فقط صفحهی پرداخت را باز میکند و بقیهی کارها را با سرور خود شما انجام میدهد
pname
نام بستهی پرداخت شما؛ ممکن است با نام بستهی پلتفرم فرق کند
sku
شناسهی محصولی که در داشبورد بازینس ثبت شده (مثلاً example_pack)
uid
شناسهی ثابت بازیکن در بازی شما، و باید عددی باشد (به شکل رشته بفرستید، مثلاً "1042"). uid غیرعددی با no_required_params رد میشود
sequenceDiagram
autonumber
participant P as Player
participant C as Your client
participant S as Your server
participant Y as Baziness Pay
P->>C: taps "Buy"
C->>S: start a purchase
S-->>C: payment id + payment page URL
C->>Y: opens the payment page (new tab)
P->>Y: pays
P->>C: returns to the game (or taps «پرداخت کردم»)
C->>S: verify my purchase
S->>Y: get_payment
Y-->>S: payment token
S->>Y: verify_payment
Y-->>S: success
S->>S: give the item — exactly once
S-->>C: done + new balances
S->>Y: consume
چه کار میکند: صفحهی پرداخت بازینس را برای یک محصول به بازیکن نشان میدهد.
چطور: وقتی بازیکن «خرید» را میزند، کلاینت از سرور شما میخواهد خرید را شروع کند. سرور یک سفارش ذخیره میکند (قدم ۴ را ببینید)، آدرس صفحه را میسازد و برمیگرداند. کلاینت آن را در تب جدید (وب) یا مرورگر سیستم (موبایل) باز میکند.
HTTP
GET {pay}/buy?sku=example_pack&uid=1042&pname=com.karizmastudio.example
→ the hosted payment page (opened in a new tab / the system browser)
پیشنهاد
مرورگرها تبی را که مستقیم با ضربهی بازیکن باز نشده باشد مسدود میکنند. اول در همان رویداد ضربه یک تب خالی باز کنید، بعد وقتی سرور جواب داد آن را به آدرس ببرید (کد کلاینت).
چه کار میکند: از بازینس میپرسد این بازیکن برای این محصول پرداخت کاملشده و هنوز مصرفنشدهای دارد یا نه، و اگر دارد توکن پرداخت را برمیگرداند.
کِی صدایش بزنید: وقتی بازیکن به بازی برمیگردد (پنجره یا اپ دوباره فوکوس میگیرد)، وقتی «پرداخت کردم» را میزند، و وقتی فروشگاه باز میشود، برای پرداختهایی که وقتی بازی بسته بود تمام شدهاند.
HTTP
POST {pay}/get_payment
Content-Type: application/json
{ "uid": "1042", "sku": "example_pack", "pname": "com.karizmastudio.example" }
→ 200 OK (paid, not consumed yet)
{ "uid": "1042", "sku": "example_pack", "token": "TEST-633…", "time": 1791366000000, "success": true }
→ 200 OK (nothing to verify yet — the player hasn't paid)
{ "error": "payment_is_not_valid", "success": false }
payment_is_not_valid یعنی «هنوز پرداختی نیست»: بیصدا منتظر بمانید.
چه کار میکند: از بازینس تأیید میگیرد که توکن پرداخت واقعی است. این همان درخواستی است که از شما محافظت میکند: هیچوقت بر اساس حرف کلاینت چیزی ندهید.
HTTP
POST {pay}/verify_payment
Content-Type: application/x-www-form-urlencoded
uid=1042&token=TEST-633…&sku=example_pack&pname=com.karizmastudio.example
→ 200 OK
{ "success": true }
هشدار
verify_payment بدنه را به شکل فرم (application/x-www-form-urlencoded) میگیرد؛ بقیهی درخواستها JSON. فرستادن JSON در اینجا رایجترین دلیل شکست تأیید است.
چه کار میکند: خرید را مصرفشده علامت میزند تا همان محصول دوباره قابل خرید باشد. تا وقتی مصرف نشده، قدم ۲ همچنان آن را برمیگرداند و خرید دوم آن SKU مسدود است.
کِی: درست بعد از ثبت نهایی قدم ۴. اگر شکست خورد چند بار دوباره تلاش کنید؛ آیتم قبلاً داده شده، پس شکست این قدم هیچوقت بازیکن را معطل نمیکند.
HTTP
POST {pay}/consume
Content-Type: application/json
{ "uid": "1042", "sku": "example_pack", "pname": "com.karizmastudio.example" }
→ 200 OK
{ "success": true }
بیلدی که در کافهبازار یا مایکت منتشر میشود باید از پرداخت درونبرنامهای خود آن فروشگاه استفاده کند، نه پرداخت بازینس. نام SKUها را در هر دو یکی نگه دارید تا یک فهرست محصول برای همهی بیلدها کافی باشد، و در آن بیلدها buildStore را روی لینک صفحههای بازینس بفرستید (پارامترها).
راهنماها
ورود و اتصال حساب بازینس
بازیکن چطور هویت بازینس میگیرد، از اسپلش یا تنظیمات وارد میشود، و بازی چطور حسابها را بیخطر جابهجا میکند.
بازی شما حسابهای بازیکن خودش را دارد، مثلاً با کلید شناسهی دستگاهی که در اولین اجرا ساخته میشود. بازینس روی همین حساب سوار میشود:
اولین باری که پلتفرم روشن است، سرور شما یک کاربر ناشناس بازینس برای بازیکن میسازد (create-user) و userId آن را روی رکورد بازیکن ذخیره میکند.
بازیکن تا وقتی با شماره موبایلش در یکی از صفحههای بازینس وارد نشود ناشناس میماند (isLinkedToAccount: false). بعد از ورود حساب متصل است.
اگر حساب بازینسی که بازیکن واردش میشود از قبل مال بازیکن دیگری از بازیکنان شما باشد (همان شخص روی گوشی دیگر)، بازی به آن بازیکن جابهجا میشود و حساب را دو بار وصل نمیکند.
وضعیت
userId بازینس ذخیره شده؟
isLinkedToAccount
چیزی که بازیکن میبیند
بازینس خاموش
—
—
هیچ بخش بازینسی
مهمان
بله (کاربر ناشناس)
false
موجودی سکه و دکمهی جایزه کار میکنند؛ «اتصال حساب» در تنظیمات
sequenceDiagram
autonumber
participant C as Game client
participant S as Game server
participant K as Platform API
participant W as Baziness page (sheet)
C->>S: GET /api/platform/config
S-->>C: { enabled, webBase, apiBase, packageName }
Note over C: Splash: Sign in with Baziness / Continue as guest
C->>S: POST /api/platform/token { deviceId, linkedToken? }
S->>K: create-user (first time) / get-user-token
K-->>S: { userId, token, isLinkedToAccount }
S-->>C: { token, bazinessUserId, isLinked, switchToDeviceId? }
C->>W: open login page (sdkType=js, linkedToken, allowGuest=false)
W-->>C: postMessage { type: "login", data: "<token>" }
C->>S: POST /api/platform/login { deviceId, token }
S->>K: validate-user-token
K-->>S: { isValid, userId }
S-->>C: { ok } — or { ok, switchToDeviceId }
C->>C: close sheet, or switch device id + reload
جابهجایی وقتی پیش میآید که حساب بازینسی که بازیکن واردش میشود از قبل به بازیکن دیگری از بازیکنان شما وصل باشد، مثلاً بعد از نصب دوباره یا روی گوشی جدید:
سرور پاسخ میدهد switchToDeviceId: "<شناسهی دستگاه آن بازیکن>".
کلاینت آن شناسه را بهعنوان شناسهی خودش ذخیره میکند و توکن بازینس ذخیرهشده را فراموش میکند.
بازی را از اول اجرا میکند، بدون linkedToken در آدرس.
اجرای بعدی بازیکن دیگر را با همهی پیشرفتش بالا میآورد.
اپ بازینس حالا ?linkCode= (یکبارمصرف) را هم میفرستد. بهجای linkedToken آن را به سرورتان بدهید. مهاجرت توکن را ببینید.
اپ بازینس بازیها را با ?linkedToken=<توکن> در آدرس باز میکند:
آن توکن را بهعنوان linkedToken به /api/platform/token بفرستید تا بازیکن در حساب درست بازینس قرار بگیرد. در اجرای بعدی آن را از آدرس پاک کنید.
اگر دستگاه از قبل توکن دیگری دارد، حساب بازینس دیگری دارد میآید: پیش از ادامه دادههای محلی را پاک کنید و از اول شروع کنید.
ورود تعیین میکند کدام حساب بازی کند، هیچوقت تعیین نمیکند بازیکن جدید است یا نه. بازیکن تازهواردی که اینطور میآید هم باید آموزش و هدیههای خوشآمد شما را ببیند.
مهمان هر وقت بخواهد میتواند حسابش را وصل کند: تنظیمات ← باز کردن صفحهی پروفایل (پارامترها). وقتی نما بسته شد، دوباره /api/platform/token را بپرسید و موجودی سکه را تازه کنید.
وقتی صفحه logout میفرستد، نما را ببندید، هر چه بازی بهصورت محلی ذخیره کرده پاک کنید (هویت، توکن، انتخابها، پیشرفت آموزش) و مثل یک بازدیدکنندهی کاملاً جدید از اول شروع کنید.
روی پلتفرم توسعه (همان جایی که بازیهای تستی به آن وصلاند) با هر شماره موبایلی و کد 111111 میتوانید وارد شوید. هیچ پیامکی فرستاده نمیشود. با شمارههای ساختگی هر چند حساب تستی که لازم دارید بسازید: یکی برای تست جابهجایی حساب، یکی برای یک مهمان تازه و غیره. محیط اصلی کد واقعی را پیامک میکند.
گیتا کوین واحد پول بازینس است. بازیکن آن را در بازی شما به دست میآورد (برد مسابقه، رتبهی تورنمنت، هدیه، خرید بسته) و در فروشگاه جایزه برای جایزههای واقعی خرج میکند. موجودی روی پلتفرم نگه داشته میشود، نه در دیتابیس شما:
flowchart LR
A["بنر لابی<br/>«انتخاب جایزه» + موجودی"] -->|ضربه| B{"پلتفرم<br/>فعال است؟"}
B -- خیر --> T["پیام: بعد از اتصال<br/>به بازینس فعال میشود"]
B -- بله --> C["باز کردن شیت:<br/>{webBase}/shop?game_name=…"]
C --> D["بازیکن جایزه انتخاب میکند<br/>و گیتا کوین خرج میکند"]
D -->|کشیدن به پایین / بستن| E["شیت بسته شد ←<br/>get-coin دوباره"]
سایت بازینس نوار ناوبری پایین خودش را دارد (فروشگاه / بازیها / خانه / ماموریتها / بازینس من). داخل بازی شاید بخواهید پنهانش کنید. صفحه مال سایت دیگری است، پس CSS و اسکریپت شما به داخلش دسترسی ندارند. به جایش:
وبویو را به اندازهی ارتفاع نوار بلندتر از ظرفش کنید.
بگذارید ظرف بخش اضافه را برش بدهد.
نوار پایین صفحه است، پس بیرون محدودهی دیدهشده میافتد و بقیهی صفحه مثل قبل اسکرول میشود. ارتفاع را روی گوشیهای واقعی تنظیم کنید. کد.
بیلدی که بدون بازینس منتشر میشود (مثلاً بعضی بیلدهای فروشگاهی) نباید دکمه را نشان دهد و نباید جایزهی گیتا کوین تبلیغ کند. بگذارید سرور تصمیم بگیرد: برای آن بیلد { enabled: false } برگرداند و کلاینت هیچکدام را نکشد.
دکمه موجودی زنده را نشان میدهد (هنگام بارگذاری یا خطا …).
/shop را با game_name، packageName، sdkType=js، linkedToken و redirect=shop باز میکند.
نه allowGuest=false دارد و نه ریشهی سایت را باز میکند.
موجودی بعد از بسته شدن نما و بعد از فوکوس دوبارهی بازی تازه میشود.
وقتی بازینس خاموش است، دکمه پنهان است یا توضیح میدهد چرا در دسترس نیست.
راهنماها
صفحهی پیشنهاد آموزش (game-offer)
صفحهای نمایشی که مخصوص بازی شما ساخته شده و یک بار وسط آموزش باز میشود تا به بازیکن تازهوارد نشان دهد با گیتا کوین چه چیزهایی میشود گرفت و یک بستهی شروع رایگان به او پیشنهاد کند.
هر بازی میتواند یک صفحهی پیشنهاد روی بازینس داشته باشد که مخصوص همان بازی است و با game_name انتخاب میشود:
Text
{web}/game-offer?game_name=<نام بازی>
این صفحه جایزههایی را که با گیتا کوین میشود گرفت نشان میدهد، بهعلاوهی یک بستهی رایگان مخصوص بازی شما؛ مثلاً صفحهی بازی نمونه «بسته ویژه بازی example — رایگان» را با دکمهی «خرید رایگان» نشان میدهد. این صفحه نمایشی است: یک بار و وسط آموزش بازش میکنید تا انتظار بازیکن شکل بگیرد. بعد از آموزش، دکمهی جایزهی شما فروشگاه جایزهی واقعی را باز میکند.
نکته
از تیم پلتفرم بخواهید صفحهی پیشنهاد بازی شما را راه بیندازد، و از همان game_name صفحهی جایزه استفاده کنید.
flowchart TD
A["درس آموزش تمام شد"] --> B["لابی: راهنما روی «انتخاب جایزه»<br/>(بقیهی صفحه تیره)"]
B -->|ضربه| C{"پلتفرم فعال است<br/>و شیت در دسترس؟"}
C -- بله --> D["باز کردن game-offer در شیت<br/>مرحله = pack"]
D -->|"دکمهی بستن · کشیدن به پایین · زدن بیرون"| E["شیت بسته شد"]
C -- خیر --> E
E --> F["پنجرهی بستهی شروع («خرید موفق»)"]
F --> G["پایان آموزش ←<br/>از این به بعد بنر /shop را باز میکند"]
پیش از باز کردن صفحه، قدم آموزش را ذخیره کنید. اگر بازیکن وقتی صفحه باز است بازی را دوباره بارگذاری کند، با بستهی شروع ادامه میدهد و پیشنهاد را دو بار نمیبیند.
به هر شکلی که صفحه بسته شد ادامه دهید: دکمهی خودش، کشیدن به پایین یا زدن بیرون آن.
آموزش را هیچوقت معطل بازینس نکنید. اگر خاموش یا در دسترس نیست، مستقیم به بستهی شروع بروید.
بستهی شروع را از سرور و یک بار برای هر بازیکن بدهید. آن را مثل یک خرید موفق («خرید موفق») نشان دهید، هرچند رایگان است.
بازیکنانی که از اپ بازینس میآیند هم آموزش را میبینند. ورود، بازیکن را «قدیمی» نمیکند.
بعد از آموزش /shop را باز کنید، دیگر هرگز صفحهی پیشنهاد را.
پیش از هر چیز بخوانیدشان، پیش از اینکه چیزی رندر شود. نامها را بدون حساسیت به بزرگی و کوچکی حروف تطبیق دهید.
از نوار آدرس پاکشان کنید. بازیکن آنها را نمیبیند و ویرایش نمیکند، و بازیکنی که آدرسش را با دوستانش به اشتراک میگذارد کمپین شما را به نام آنها ثبت نمیکند.
بار اول روی دستگاه ذخیرهشان کنید («اولین تماس»)، و فقط اگر هنوز چیزی ذخیره نشده. ورود بازینس یا جابهجایی حساب بازی را از اول اجرا میکند و بدون ذخیرهی محلی برچسبها گم میشوند.
با اولین درخواستی که بازیکن را میسازد بفرستیدشان، هم ورود معمولی خودتان و هم /api/platform/token، هر کدام که زودتر اجرا شود.
در سرور فقط هنگام ساخت بازیکن ذخیرهشان کنید و بعداً هرگز بازنویسی نکنید.
چهار فیلد جدا نگه دارید (utm_source، utm_medium، utm_campaign، utm_content)، نه یک رشتهی چسبیده. نام کمپینها خودشان زیرخط دارند، پس مقدار چسبیده را نمیشود دوباره جدا کرد.
به linkedToken دست نزنید. برچسب کمپین نیست: منطق ورود از آن استفاده میکند و در اجرای بعدی پاکش میکند.
به تیم بازاریابی یک دستور ثابت یا یک سازندهی لینک کوچک بدهید تا هر لینک هر چهار پارامتر را با املای یکسان داشته باشد: آدرس بازی شما بهعلاوهی utm_source، utm_medium، utm_campaign و (اختیاری) utm_content، همه با حروف کوچک ASCII.
پلتفرم توکنهای بلندعمر کاربر را با توکنهای ۱۵ دقیقهای و کدهای اتصال یکبارمصرف جایگزین میکند. چه چیزی در سرور و کلاینت شما عوض میشود، قدمبهقدم، و تا کی.
تا الان توکن کاربر بازینس ۱۰ سال اعتبار داشت. توی آدرسها هم جابهجا میشد:
بازی شما صفحههای بازینس را با ?linkedToken=<token> باز میکرد؛
اپ بازینس بازی شما را با ?linkedToken=<token> باز میکرد.
آدرسها در لاگ سرورها، ابزارهای آنالیتیکس، تاریخچهی مرورگر و هدر Referer ثبت میشوند. توکنی که از آنجا پیدا میشد، تا ده سال به سکهها و حساب بازیکن دسترسی میداد و خروج از حساب هم جلویش را نمیگرفت.
مدل جدید:
قبل
حالا
توکن دسترسی (چیزی که کلاینت بهعنوان Bearer میفرستد)
۱۰ سال اعتبار
۱۵ دقیقه اعتبار
توکن تمدید (refresh token)
—
توکن دسترسی را تمدید میکند؛ روی سرور شما نگه داشته میشود، هیچوقت روی دستگاه؛ یکبارمصرف است و هر تمدید یک توکن تازه برمیگرداند
جابهجا کردن بازیکن بین اپها
?linkedToken=<token>
?linkCode=<code>: یک کد یکبارمصرف با حدود ۲ دقیقه اعتبار که بعد از اولین استفاده بیاثر است
مهم
در دورهی مهاجرت هر دو مدل کنار هم کار میکنند. endpointهای قدیمی و ?linkedToken= کار میکنند و اپ بازینس هم ?linkCode= و هم ?linkedToken= را برای بازیها میفرستد. تیم پلتفرم یک تاریخ پایان اعلام میکند: بعد از آن، توکنهای قدیمی و linkedToken دیگر کار نمیکنند. مهاجرت را قبل از آن تاریخ انجام دهید.
جریان بعد از مهاجرت:
sequenceDiagram
autonumber
participant C as کلاینت بازی
participant S as سرور بازی
participant K as API پلتفرم
participant W as صفحهی بازینس (شیت)
C->>S: POST /api/platform/token { deviceId, linkCode? }
alt بار اول، یا ورود با ?linkCode=
S->>K: POST v2/client/create-user { deviceId, linkCode }
else بازیکن آشنا
S->>K: POST auth/refresh { refreshToken ذخیرهشده }
end
K-->>S: { userId, isLinkedToAccount, accessToken (۱۵ دقیقه), refreshToken }
Note over S: refreshToken جدید را روی بازیکن ذخیره کن
S-->>C: { token: accessToken, tokenExpiresAt, bazinessUserId, isLinked }
C->>S: POST /api/platform/link-code (قبل از باز کردن صفحه)
S->>K: POST v2/client/link-code/{userId}
K-->>S: { code } (یکبارمصرف، حدود ۲ دقیقه)
S-->>C: { linkCode }
C->>W: باز کردن صفحه (sdkType=js, linkCode, …)
W-->>C: postMessage { type: "login", data: "<token>" }
C->>S: POST /api/platform/login { deviceId, token }
S->>K: POST client/validate-user-token { token }
S-->>C: { ok } — یا { ok, switchToDeviceId }
این endpointی است که کلاینت در شروع اجرا صدا میزند. کلاینت حالا بهجای linkedToken، linkCode میفرستد.
کلاینت linkCode فرستاده، یا بازیکن هنوز کاربر بازینس ندارد:v2/client/create-user را با deviceId و linkCode صدا بزنید. بعد userId برگشتی را مثل امروز بررسی کنید:
userId مال بازیکن دیگری از بازیکنان شماست ← switchToDeviceId برگردانید (همان آدم روی دستگاه دیگر).
بازیکن از قبل userId دیگری دارد ← یک حساب بازینس دیگر رسیده است. آن را بهعنوان بازیکن جدا شروع کنید و switchToDeviceId برگردانید (همان قاعدهی قدیمی «دادههای محلی را پاک کن و از نو شروع کن»).
در غیر این صورت ← userId و توکن تمدید را روی همین بازیکن ذخیره کنید.
اگر کد منقضی یا مصرفشده بود (400 invalid_link_code)، طوری ادامه دهید که انگار کدی نیامده.
بازیکن آشنا:auth/refresh را با توکن تمدید ذخیرهشده صدا بزنید و توکن تازه را ذخیره کنید. جواب isLinkedToAccount را هم دارد، پس همچنان میفهمید بازیکن کی وارد حسابش شده.
401 refresh_token_rotated: درخواست دیگری از همین بازیکن لحظهای قبل تمدید کرده. توکن تمدید را دوباره از دیتابیس بخوانید و یک بار دیگر امتحان کنید.
هر 401 دیگر (session_revoked، refresh_token_expired، invalid_refresh_token، refresh_token_reused): نشست تمام شده. با v2/client/get-user-token/{userId} نشست تازه بگیرید و توکن تمدیدش را ذخیره کنید.
403 account_suspended: حساب مسدود است.
به کلاینتتوکن دسترسی و زمان انقضایش را بدهید: { token, tokenExpiresAt, bazinessUserId, isLinkedToAccount }. توکن تمدید را هرگز نفرستید.
پیشنهاد
بازیکنهایی که از قبل دارید هنوز توکن تمدید ندارند. قدم ۲ خودش آنها را درست میکند: توکن تمدید ذخیرهشدهای نیست ← یک بار v2/client/get-user-token، و از آن به بعد auth/refresh.
#۳. endpoint جدید /api/platform/link-code: یک کد برای هر صفحه
وقتی کلاینت میخواهد یکی از صفحههای بازینس را باز کند، از سرور شما کد میخواهد. سرور شما v2/client/link-code/{userId} را صدا میزند و { linkCode } را برمیگرداند.
هر کد یک بار و حدود دو دقیقه کار میکند: برای هر صفحهای که باز میکنید کد تازه بگیرید.
فقط صفحههای بازینس میتوانند از آن استفاده کنند. حتی اگر لو برود، نمیشود با آن وارد بازی شما شد.
این endpoint را مثل بقیهی endpointهای بازیکن محافظت کنید: فقط خود بازیکن میتواند کد خودش را بگیرد.
اضافهها به کلاینت پلتفرم شما (کنار createUser / getUserAccessToken):
TypeScript (Node)
// platform/baziness.ts — ADD these next to createUser / getUserAccessToken (they reuse API_BASE and platformFetch)./** A player's session: the access token goes to your client, the refresh token stays in your database. */exportinterfacePlatformSession {
userId: number;
isLinkedToAccount: boolean;
accessToken: string;
accessTokenExpiresAt: string;
refreshToken: string;
}
const readSession = (j: Record<string, any>): PlatformSession => {
const t = j.tokens ?? j; // v2 create-user / get-user-token nest the pair in `tokens`; auth/refresh returns it flatreturn {
userId: Number(j.userId),
isLinkedToAccount: Boolean(j.isLinkedToAccount),
accessToken: String(t.accessToken),
accessTokenExpiresAt: String(t.accessTokenExpiresAt),
refreshToken: String(t.refreshToken),
};
};
/** First time, or when the client brought a ?linkCode=. Throws (status 400) for an expired or used code. */exportconstcreateUserSession = async (deviceId: string, platform: string, store: string, linkCode: string | null) =>
readSession(awaitplatformFetch("POST", "/v2/client/create-user", { deviceId, platform, store, linkCode }));
/** A new session for a player you know — only when you have no refresh token or the session ended. */exportconststartUserSession = async (bazinessUserId: number) =>
readSession(awaitplatformFetch("POST", `/v2/client/get-user-token/${bazinessUserId}`));
/**
* Renews a session. Returns the new session, "rotated" when a parallel request refreshed first
* (reload the stored token and retry once), or null when the session is over (start a new one).
*/exportasyncfunctionrefreshUserSession(refreshToken: string): Promise<PlatformSession | "rotated" | null> {
const res = awaitfetch(`${API_BASE}/auth/refresh`, { // no client token: the refresh token is the credentialmethod: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (res.ok) returnreadSession(await res.json());
const error = ((await res.json().catch(() => ({}))) as { error?: string }).error;
if (res.status === 401 && error === "refresh_token_rotated") return"rotated";
if (res.status === 403) throwObject.assign(newError("account_suspended"), { status: 403 });
if (res.status === 401) returnnull;
thrownewError(`auth/refresh failed (${res.status})`); // 5xx / network: keep the old session, try later
}
/** Ends a session (player signed out). Never throws. */exportasyncfunctionendUserSession(refreshToken: string): Promise<void> {
awaitfetch(`${API_BASE}/auth/logout`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
}).catch(() => {});
}
/** One-time code for opening ONE Baziness page as this player (~2 minutes). */exportconstcreatePageLinkCode = async (bazinessUserId: number) =>
String((awaitplatformFetch("POST", `/v2/client/link-code/${bazinessUserId}`)).code);
/** REPLACES the old GET version: the token now goes in the body, not in the URL. */exportasyncfunctionvalidateUserAccessToken(token: string): Promise<number> {
const j = awaitplatformFetch("POST", "/client/validate-user-token", { token });
if (!(j.isValid ?? j.IsValid)) thrownewError("invalid user token");
returnNumber(j.userId ?? j.UserId ?? 0);
}
C# (.NET)
// BazinessPlatformClient.cs — ADD these members (they reuse settings, http, SendAsync and Json).publicrecordPlatformSession(long UserId, bool IsLinkedToAccount, string AccessToken,
DateTimeOffset AccessTokenExpiresAt, string RefreshToken);
publicenum RefreshOutcome { Ok, Rotated, Ended }
static PlatformSession ReadSession(JsonElement j)
{
// v2 create-user / get-user-token nest the pair in "tokens"; auth/refresh returns it flat.var t = j.TryGetProperty("tokens", outvar nested) ? nested : j;
returnnew PlatformSession(
j.GetProperty("userId").GetInt64(),
j.GetProperty("isLinkedToAccount").GetBoolean(),
t.GetProperty("accessToken").GetString()!,
t.GetProperty("accessTokenExpiresAt").GetDateTimeOffset(),
t.GetProperty("refreshToken").GetString()!);
}
///<summary>First time, or when the client brought a ?linkCode=. 400 = expired or used code.</summary>publicasync Task<PlatformSession> CreateUserSessionAsync(string deviceId, string platform, string store, string? linkCode) =>
ReadSession(await SendAsync(HttpMethod.Post, "/v2/client/create-user", new { deviceId, platform, store, linkCode }));
///<summary>A new session for a known player — only when there is no refresh token or the session ended.</summary>publicasync Task<PlatformSession> StartUserSessionAsync(long bazinessUserId) =>
ReadSession(await SendAsync(HttpMethod.Post, $"/v2/client/get-user-token/{bazinessUserId}"));
///<summary>Renews a session. Rotated = a parallel request refreshed first (reload, retry once); Ended = start a new one.</summary>publicasync Task<(RefreshOutcome Outcome, PlatformSession? Session)> RefreshUserSessionAsync(string refreshToken)
{
// No client token here: the refresh token itself is the credential.var res = await http.PostAsJsonAsync($"{settings.ApiBase}/auth/refresh", new { refreshToken }, Json);
if (res.IsSuccessStatusCode)
return (RefreshOutcome.Ok, ReadSession(await res.Content.ReadFromJsonAsync<JsonElement>(Json)));
string? error = null;
var text = await res.Content.ReadAsStringAsync();
if (!string.IsNullOrEmpty(text))
{
try { error = JsonDocument.Parse(text).RootElement.TryGetProperty("error", outvar e) ? e.GetString() : null; }
catch (JsonException) { /* not JSON */ }
}
return res.StatusCode switch
{
HttpStatusCode.Unauthorized when error == "refresh_token_rotated" => (RefreshOutcome.Rotated, null),
HttpStatusCode.Unauthorized => (RefreshOutcome.Ended, null),
HttpStatusCode.Forbidden => thrownew InvalidOperationException("account_suspended"),
_ => thrownew HttpRequestException($"auth/refresh failed ({(int)res.StatusCode})") // keep the old session
};
}
///<summary>Ends a session (player signed out). Never throws.</summary>publicasync Task EndUserSessionAsync(string refreshToken)
{
try { await http.PostAsJsonAsync($"{settings.ApiBase}/auth/logout", new { refreshToken }, Json); }
catch { /* best effort */ }
}
///<summary>One-time code for opening ONE Baziness page as this player (~2 minutes).</summary>publicasync Task<string> CreatePageLinkCodeAsync(long bazinessUserId) =>
(await SendAsync(HttpMethod.Post, $"/v2/client/link-code/{bazinessUserId}")).GetProperty("code").GetString()!;
///<summary>REPLACES the old GET version: the token now goes in the body, not in the URL.</summary>publicasync Task<long> ValidateUserAccessTokenAsync(string token)
{
var j = await SendAsync(HttpMethod.Post, "/client/validate-user-token", new { token });
if (!j.GetProperty("isValid").GetBoolean()) thrownew InvalidOperationException("invalid user token");
return j.GetProperty("userId").GetInt64();
}
Python
# baziness/platform.py — ADD these methods to BazinessPlatform (they reuse API_BASE, self._http and self._call). @staticmethoddef_session(j: dict) -> dict:
t = j.get("tokens") or j # v2 create-user / get-user-token nest the pair; auth/refresh returns it flatreturn {"userId": int(j["userId"]), "isLinkedToAccount": bool(j["isLinkedToAccount"]),
"accessToken": t["accessToken"], "accessTokenExpiresAt": t["accessTokenExpiresAt"],
"refreshToken": t["refreshToken"]}
defcreate_user_session(self, device_id: str, platform: str, store: str, link_code: str | None) -> dict:
"""First time, or when the client brought a ?linkCode=. HTTPError 400 = expired or used code."""returnself._session(self._call("POST", "/v2/client/create-user", {
"deviceId": device_id, "platform": platform, "store": store, "linkCode": link_code}))
defstart_user_session(self, baziness_user_id: int) -> dict:
"""A new session for a known player — only when there is no refresh token or the session ended."""returnself._session(self._call("POST", f"/v2/client/get-user-token/{baziness_user_id}"))
defrefresh_user_session(self, refresh_token: str):
"""Returns the new session, "rotated" (reload the stored token, retry once) or None (session over)."""
r = self._http.post(f"{API_BASE}/auth/refresh", json={"refreshToken": refresh_token}, timeout=10)
if r.ok:
returnself._session(r.json())
error = (r.json() if r.content else {}).get("error")
if r.status_code == 401:
return"rotated"if error == "refresh_token_rotated"elseNoneif r.status_code == 403:
raise PermissionError("account_suspended")
r.raise_for_status() # 5xx: keep the old session, try laterdefend_user_session(self, refresh_token: str) -> None:
"""Ends a session (player signed out). Never raises."""try:
self._http.post(f"{API_BASE}/auth/logout", json={"refreshToken": refresh_token}, timeout=10)
except requests.RequestException:
passdefcreate_page_link_code(self, baziness_user_id: int) -> str:
"""One-time code for opening ONE Baziness page as this player (~2 minutes)."""returnstr(self._call("POST", f"/v2/client/link-code/{baziness_user_id}")["code"])
defvalidate_user_access_token(self, token: str) -> int:
"""REPLACES the old GET version: the token now goes in the body, not in the URL."""
j = self._call("POST", "/client/validate-user-token", {"token": token})
ifnot _pick(j, "isValid", False):
raise ValueError("invalid user token")
returnint(_pick(j, "userId", 0))
چهار endpoint:
TypeScript (Node)
import {
createUserSession, startUserSession, refreshUserSession, endUserSession, createPageLinkCode,
validateUserAccessToken, platformClientConfig, platformEnabled, typePlatformSession,
} from"./platform/baziness";
import { db } from"./db"; // your own data layer. New column on the player: baziness_refresh_token (a secret)// What the client gets. The refresh token never leaves your server.constforClient = (s: PlatformSession) => ({
token: s.accessToken, tokenExpiresAt: s.accessTokenExpiresAt,
bazinessUserId: s.userId, isLinkedToAccount: s.isLinkedToAccount,
});
asyncfunctionkeep(playerId: number, s: PlatformSession) {
await db.setRefreshToken(playerId, s.refreshToken); // always the NEWEST one: the old one stops workingreturn s;
}
/** A valid session for a player who already has a Baziness user. */asyncfunctionsessionFor(player: { id: number; bazinessUserId: number; bazinessRefreshToken: string | null }) {
if (player.bazinessRefreshToken) {
let r = awaitrefreshUserSession(player.bazinessRefreshToken);
if (r === "rotated") { // another request of this player refreshed a moment agoconst latest = await db.getRefreshToken(player.id);
r = latest ? awaitrefreshUserSession(latest) : null;
if (r === "rotated") r = null;
}
if (r) returnkeep(player.id, r);
}
// No refresh token yet (players from before the migration) or the session ended.returnkeep(player.id, awaitstartUserSession(player.bazinessUserId));
}
app.post("/api/platform/token", async (req, res) => {
const deviceId = String(req.body.deviceId ?? "").trim();
constlinkCode: string | null = req.body.linkCode ?? null; // replaces linkedTokenif (!deviceId) return res.status(400).json({ error: "deviceId required" });
if (!platformEnabled()) return res.json({ enabled: false });
const config = { enabled: true, ...platformClientConfig() };
const me = await db.ensureUserByDevice(deviceId, { utm: req.body.utm, tag: req.body.tag });
try {
if (linkCode || !me.bazinessUserId) {
// First time, or arrived from the Baziness app with ?linkCode=: which Baziness user is this?const s = awaitcreateUserSession(deviceId, "Web", "Direct", linkCode)
.catch((e) => (linkCode && e.status === 400 ? null : Promise.reject(e))); // expired or used codeif (s) {
const owner = await db.findUserByBazinessId(s.userId);
if (owner && owner.id !== me.id) { // the same person, another player of yoursawaitkeep(owner.id, s);
return res.json({ ...config, switchToDeviceId: owner.deviceId });
}
if (me.bazinessUserId && me.bazinessUserId !== s.userId) {
// A different Baziness account arrived on a device that already has a player:// it becomes its own player (the old "clear local data and start again" rule).const fresh = await db.createPlayerForBaziness(s.userId, s.isLinkedToAccount);
awaitkeep(fresh.id, s);
return res.json({ ...config, switchToDeviceId: fresh.deviceId });
}
await db.linkBaziness(me.id, s.userId, s.isLinkedToAccount);
awaitkeep(me.id, s);
return res.json({ ...config, ...forClient(s) });
}
if (!me.bazinessUserId) { // stale code and no Baziness user yetconst s2 = awaitcreateUserSession(deviceId, "Web", "Direct", null);
await db.linkBaziness(me.id, s2.userId, s2.isLinkedToAccount);
awaitkeep(me.id, s2);
return res.json({ ...config, ...forClient(s2) });
}
}
const s = awaitsessionFor(me);
if (s.isLinkedToAccount !== me.isBazinessLinked) await db.setLinked(me.id, s.isLinkedToAccount);
return res.json({ ...config, ...forClient(s) });
} catch (err) {
console.error("[platform] token failed:", (err asError).message);
const suspended = (err as { status?: number }).status === 403;
return res.json({ ...config, error: suspended ? "account_suspended" : "platform_unavailable" }); // never block the boot
}
});
// Protect it like your other player endpoints: only the player themself may ask for their code.
app.post("/api/platform/link-code", async (req, res) => {
const me = await db.findUserByDevice(String(req.body.deviceId ?? ""));
if (!me?.bazinessUserId) return res.status(409).json({ error: "call /api/platform/token first" });
try {
return res.json({ linkCode: awaitcreatePageLinkCode(me.bazinessUserId) });
} catch {
return res.status(503).json({ error: "platform_unavailable" });
}
});
app.post("/api/platform/login", async (req, res) => {
const deviceId = String(req.body.deviceId ?? "").trim();
const token = String(req.body.token ?? "").trim();
if (!deviceId || !token) return res.status(400).json({ error: "deviceId and token required" });
if (!platformEnabled()) return res.json({ ok: false, reason: "disabled" });
try {
const bazinessUserId = awaitvalidateUserAccessToken(token); // POST now; verify right away (15-min token)const owner = await db.findUserByBazinessId(bazinessUserId);
const me = await db.ensureUserByDevice(deviceId);
if (owner && owner.id !== me.id) return res.json({ ok: true, switchToDeviceId: owner.deviceId });
if (me.bazinessUserId !== bazinessUserId) await db.setRefreshToken(me.id, null); // belonged to the old userawait db.linkBaziness(me.id, bazinessUserId, true);
return res.json({ ok: true }); // the client then calls /token again
} catch {
return res.json({ ok: false, reason: "invalid" });
}
});
// The page sent `logout`: end the session before the client starts over as a new visitor.
app.post("/api/platform/logout", async (req, res) => {
const me = await db.findUserByDevice(String(req.body.deviceId ?? ""));
if (me?.bazinessRefreshToken) {
awaitendUserSession(me.bazinessRefreshToken);
await db.setRefreshToken(me.id, null);
}
return res.json({ ok: true });
});
C# (.NET)
// IUserStore gains: SetRefreshTokenAsync(id, token?), GetRefreshTokenAsync(id), FindByDeviceAsync(deviceId),// CreatePlayerForBazinessAsync(bazinessUserId, isLinked). Store the refresh token like a password.// What the client gets. The refresh token never leaves your server.staticobjectForClient(PlatformSettings pub, PlatformSession s) => new
{
enabled = true, pub.WebBase, pub.ApiBase, pub.PackageName,
token = s.AccessToken, tokenExpiresAt = s.AccessTokenExpiresAt,
bazinessUserId = s.UserId, isLinkedToAccount = s.IsLinkedToAccount
};
staticasync Task<PlatformSession> SessionForAsync(Player player, BazinessPlatformClient platform, IUserStore users)
{
var refreshToken = player.BazinessRefreshToken;
for (var attempt = 0; refreshToken isnotnull && attempt < 2; attempt++)
{
var (outcome, session) = await platform.RefreshUserSessionAsync(refreshToken);
if (outcome == RefreshOutcome.Ok)
{
await users.SetRefreshTokenAsync(player.Id, session!.RefreshToken); // always the newest onereturn session;
}
if (outcome == RefreshOutcome.Ended) break;
refreshToken = await users.GetRefreshTokenAsync(player.Id); // Rotated: a parallel request won
}
// No refresh token yet (players from before the migration) or the session ended.var fresh = await platform.StartUserSessionAsync(player.BazinessUserId!.Value);
await users.SetRefreshTokenAsync(player.Id, fresh.RefreshToken);
return fresh;
}
app.MapPost("/api/platform/token", async (TokenRequest body, BazinessPlatformClient platform,
IUserStore users, PlatformSettings pub, ILogger<Program> log) =>
{
if (string.IsNullOrWhiteSpace(body.DeviceId)) return Results.BadRequest(new { error = "deviceId required" });
if (!pub.IsOn) return Results.Ok(new { enabled = false });
var me = await users.EnsureByDeviceAsync(body.DeviceId);
try
{
if (body.LinkCode isnotnull || me.BazinessUserId isnull)
{
PlatformSession? s = null;
try { s = await platform.CreateUserSessionAsync(body.DeviceId, "Web", "Direct", body.LinkCode); }
catch (HttpRequestException e) when (body.LinkCode isnotnull && e.StatusCode == HttpStatusCode.BadRequest) { }
if (s isnotnull)
{
var owner = await users.FindByBazinessUserIdAsync(s.UserId);
if (owner isnotnull && owner.Id != me.Id)
{
await users.SetRefreshTokenAsync(owner.Id, s.RefreshToken);
return Results.Ok(new { enabled = true, pub.WebBase, pub.ApiBase, pub.PackageName, switchToDeviceId = owner.DeviceId });
}
if (me.BazinessUserId isnotnull && me.BazinessUserId != s.UserId)
{
var created = await users.CreatePlayerForBazinessAsync(s.UserId, s.IsLinkedToAccount);
await users.SetRefreshTokenAsync(created.Id, s.RefreshToken);
return Results.Ok(new { enabled = true, pub.WebBase, pub.ApiBase, pub.PackageName, switchToDeviceId = created.DeviceId });
}
await users.LinkBazinessAsync(me.Id, s.UserId, s.IsLinkedToAccount);
await users.SetRefreshTokenAsync(me.Id, s.RefreshToken);
return Results.Ok(ForClient(pub, s));
}
if (me.BazinessUserId isnull) // stale code and no Baziness user yet
{
var s2 = await platform.CreateUserSessionAsync(body.DeviceId, "Web", "Direct", null);
await users.LinkBazinessAsync(me.Id, s2.UserId, s2.IsLinkedToAccount);
await users.SetRefreshTokenAsync(me.Id, s2.RefreshToken);
return Results.Ok(ForClient(pub, s2));
}
}
var session = await SessionForAsync(me, platform, users);
if (session.IsLinkedToAccount != me.IsBazinessLinked) await users.SetLinkedAsync(me.Id, session.IsLinkedToAccount);
return Results.Ok(ForClient(pub, session));
}
catch (Exception ex)
{
log.LogError(ex, "platform token failed");
var error = ex.Message == "account_suspended" ? "account_suspended" : "platform_unavailable";
return Results.Ok(new { enabled = true, pub.WebBase, pub.ApiBase, pub.PackageName, error }); // never block the boot
}
});
// Protect it like your other player endpoints: only the player themself may ask for their code.
app.MapPost("/api/platform/link-code", async (DeviceRequest body, BazinessPlatformClient platform, IUserStore users) =>
{
var me = await users.FindByDeviceAsync(body.DeviceId);
if (me?.BazinessUserId isnull) return Results.Conflict(new { error = "call /api/platform/token first" });
try { return Results.Ok(new { linkCode = await platform.CreatePageLinkCodeAsync(me.BazinessUserId.Value) }); }
catch { return Results.StatusCode(503); }
});
app.MapPost("/api/platform/login", async (LoginRequest body, BazinessPlatformClient platform, IUserStore users, PlatformSettings pub) =>
{
if (string.IsNullOrWhiteSpace(body.DeviceId) || string.IsNullOrWhiteSpace(body.Token))
return Results.BadRequest(new { error = "deviceId and token required" });
if (!pub.IsOn) return Results.Ok(new { ok = false, reason = "disabled" });
try
{
long bazinessUserId = await platform.ValidateUserAccessTokenAsync(body.Token); // POST now; verify right awayvar owner = await users.FindByBazinessUserIdAsync(bazinessUserId);
var me = await users.EnsureByDeviceAsync(body.DeviceId);
if (owner isnotnull && owner.Id != me.Id) return Results.Ok(new { ok = true, switchToDeviceId = owner.DeviceId });
if (me.BazinessUserId != bazinessUserId) await users.SetRefreshTokenAsync(me.Id, null); // belonged to the old userawait users.LinkBazinessAsync(me.Id, bazinessUserId, isLinked: true);
return Results.Ok(new { ok = true }); // the client then calls /token again
}
catch { return Results.Ok(new { ok = false, reason = "invalid" }); }
});
// The page sent `logout`: end the session before the client starts over as a new visitor.
app.MapPost("/api/platform/logout", async (DeviceRequest body, BazinessPlatformClient platform, IUserStore users) =>
{
var me = await users.FindByDeviceAsync(body.DeviceId);
if (me?.BazinessRefreshToken is { } refreshToken)
{
await platform.EndUserSessionAsync(refreshToken);
await users.SetRefreshTokenAsync(me.Id, null);
}
return Results.Ok(new { ok = true });
});
publicrecordTokenRequest(string DeviceId, string? LinkCode);
publicrecordDeviceRequest(string DeviceId);
publicrecordLoginRequest(string DeviceId, string Token);
Python
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from requests import HTTPError
from baziness.platform import platform, platform_client_config, platform_enabled
from app import db # your own data layer. New column on the player: baziness_refresh_token (a secret)
app = FastAPI()
classTokenRequest(BaseModel):
deviceId: str
linkCode: str | None = None# replaces linkedTokenclassDeviceRequest(BaseModel):
deviceId: strclassLoginRequest(BaseModel):
deviceId: str
token: strdeffor_client(s: dict) -> dict: # the refresh token never leaves your serverreturn {"token": s["accessToken"], "tokenExpiresAt": s["accessTokenExpiresAt"],
"bazinessUserId": s["userId"], "isLinkedToAccount": s["isLinkedToAccount"]}
defkeep(player_id: int, s: dict) -> dict:
db.set_refresh_token(player_id, s["refreshToken"]) # always the newest onereturn s
defsession_for(player) -> dict:
token = player.baziness_refresh_token
for _ inrange(2):
ifnot token:
break
r = platform.refresh_user_session(token)
ifisinstance(r, dict):
return keep(player.id, r)
if r isNone: # the session endedbreak
token = db.get_refresh_token(player.id) # "rotated": a parallel request won# No refresh token yet (players from before the migration) or the session ended.return keep(player.id, platform.start_user_session(player.baziness_user_id))
@app.post("/api/platform/token")defplatform_token(body: TokenRequest):
ifnot platform_enabled():
return {"enabled": False}
config = {"enabled": True, **platform_client_config()}
me = db.ensure_user_by_device(body.deviceId)
try:
if body.linkCode ornot me.baziness_user_id:
try:
s = platform.create_user_session(body.deviceId, "Web", "Direct", body.linkCode)
except HTTPError as e:
ifnot (body.linkCode and e.response.status_code == 400):
raise
s = None# expired or used codeif s:
owner = db.find_user_by_baziness_user_id(s["userId"])
if owner and owner.id != me.id: # the same person, another player of yours
keep(owner.id, s)
return {**config, "switchToDeviceId": owner.device_id}
if me.baziness_user_id and me.baziness_user_id != s["userId"]:
fresh = db.create_player_for_baziness(s["userId"], s["isLinkedToAccount"])
keep(fresh.id, s)
return {**config, "switchToDeviceId": fresh.device_id}
db.link_baziness(me.id, s["userId"], s["isLinkedToAccount"])
return {**config, **for_client(keep(me.id, s))}
ifnot me.baziness_user_id: # stale code and no Baziness user yet
s2 = platform.create_user_session(body.deviceId, "Web", "Direct", None)
db.link_baziness(me.id, s2["userId"], s2["isLinkedToAccount"])
return {**config, **for_client(keep(me.id, s2))}
s = session_for(me)
if s["isLinkedToAccount"] != me.is_baziness_linked:
db.set_linked(me.id, s["isLinkedToAccount"])
return {**config, **for_client(s)}
except PermissionError:
return {**config, "error": "account_suspended"}
except Exception as err: # never block the bootprint("[platform] token failed:", err)
return {**config, "error": "platform_unavailable"}
@app.post("/api/platform/link-code") # protect it like your other player endpointsdefplatform_link_code(body: DeviceRequest):
me = db.find_user_by_device(body.deviceId)
ifnot me ornot me.baziness_user_id:
raise HTTPException(409, "call /api/platform/token first")
return {"linkCode": platform.create_page_link_code(me.baziness_user_id)}
@app.post("/api/platform/login")defplatform_login(body: LoginRequest):
ifnot platform_enabled():
return {"ok": False, "reason": "disabled"}
try:
baziness_user_id = platform.validate_user_access_token(body.token) # POST now; verify right awayexcept Exception:
return {"ok": False, "reason": "invalid"}
owner = db.find_user_by_baziness_user_id(baziness_user_id)
me = db.ensure_user_by_device(body.deviceId)
if owner and owner.id != me.id:
return {"ok": True, "switchToDeviceId": owner.device_id}
if me.baziness_user_id != baziness_user_id:
db.set_refresh_token(me.id, None) # belonged to the old user
db.link_baziness(me.id, baziness_user_id, True)
return {"ok": True} # the client then calls /token again@app.post("/api/platform/logout") # the page sent `logout`defplatform_logout(body: DeviceRequest):
me = db.find_user_by_device(body.deviceId)
if me and me.baziness_refresh_token:
platform.end_user_session(me.baziness_refresh_token)
db.set_refresh_token(me.id, None)
return {"ok": True}
در شروع اجرا، بهجای linkedToken، linkCode را (از ?linkCode= در آدرس یا از deep link) به /api/platform/token بفرستید. بعد از آن linkCode و linkedToken را از آدرس حذف کنید: هر کد یک بار کار میکند.
توکن را برای اجراهای بعدی ذخیره نکنید (localStorage، PlayerPrefs). سرور شما نشست را نگه میدارد و با هر /api/platform/token یک توکن تازه میدهد.
قبل از استفاده از توکن (user/get-coin) tokenExpiresAt را نگاه کنید. اگر تا یک دقیقهی دیگر منقضی میشود، یا درخواستی 401 گرفت، دوباره /api/platform/token را صدا بزنید.
برای باز کردن صفحهی بازینس، از /api/platform/link-code کد بگیرید و بهجای linkedToken، linkCode=<code> را در آدرس صفحه بگذارید. بقیهی پارامترها همان قبلیها هستند (صفحهها).
با پیام logout صفحه، /api/platform/logout را صدا بزنید و بعد مثل قبل از نو شروع کنید.
TypeScript
// PlatformController — the parts that change.// 1. Boot: forward the one-time ?linkCode= (it replaces ?linkedToken=). Don't keep the token in// localStorage any more: your server keeps the session and gives you a fresh token on demand.asyncinit(): Promise<boolean> {
const params = newURLSearchParams(location.search);
const linkCode = params.get("linkCode");
const res = awaitfetch("/api/platform/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceId: this.deviceId, linkCode }),
});
const data = await res.json();
if (data.switchToDeviceId) returnthis.switchTo(data.switchToDeviceId); // unchangedthis.token = data.token ?? null;
this.tokenExpiresAt = data.tokenExpiresAt ? Date.parse(data.tokenExpiresAt) : 0;
if (linkCode || params.has("linkedToken")) { // a code works once
params.delete("linkCode"); params.delete("linkedToken");
const rest = params.toString();
history.replaceState(null, "", rest ? `${location.pathname}?${rest}` : location.pathname);
}
return !!this.token;
}
// 2. The token lives 15 minutes: ask your server again shortly before it expires, or after a 401.asyncaccessToken(): Promise<string | null> {
if (!this.token || this.tokenExpiresAt - Date.now() < 60_000) awaitthis.init();
returnthis.token;
}
asyncgetCoin(): Promise<number> {
constcall = async () => fetch(`${this.apiBase}/user/get-coin`, {
headers: { Authorization: `Bearer ${awaitthis.accessToken()}` },
});
let res = awaitcall();
if (res.status === 401) { this.token = null; res = awaitcall(); } // renew once, then give upreturn res.ok ? Number((await res.json()).amount ?? 0) : 0;
}
// 3. Pages: a fresh one-time code for every page you open, instead of the token.asyncpageUrl(path: string, o: PageOptions = {}): Promise<string> {
const res = awaitfetch("/api/platform/link-code", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceId: this.deviceId }),
});
const { linkCode } = await res.json();
const url = newURL(bazinessPageUrl(this.webBase, path, this.packageName, null, o)); // no token any more
url.searchParams.set("linkCode", linkCode);
return url.toString();
}
// 4. The page sent `logout`: tell your server first, then start over as before.asynconPageLogout() {
awaitfetch("/api/platform/logout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceId: this.deviceId }),
}).catch(() => {});
this.resetAndRestart(); // unchanged: delete local data, new visitor
}
C# (Unity)
// BazinessLogin — the parts that change.public DateTimeOffset TokenExpiresAt { get; privateset; }
[Serializable] classLinkCodeResponse { publicstring linkCode; }
// 1. Boot: forward the one-time ?linkCode= / deep-link code (it replaces linkedToken).// Stop saving the token in PlayerPrefs: your server keeps the session.public IEnumerator Init(string deviceId, string linkCode = null)
{
var body = $"{{\"deviceId\":\"{deviceId}\",\"linkCode\":{Quote(linkCode)}}}";
usingvar req = Post("/api/platform/token", body);
yieldreturn req.SendWebRequest();
var r = JsonUtility.FromJson<TokenResponse>(req.downloadHandler.text); // TokenResponse gains: string tokenExpiresAtif (!string.IsNullOrEmpty(r.switchToDeviceId)) { SwitchTo(r.switchToDeviceId); yieldbreak; } // unchanged
Token = r.token;
TokenExpiresAt = DateTimeOffset.TryParse(r.tokenExpiresAt, outvar t) ? t : DateTimeOffset.MinValue;
}
// 2. The token lives 15 minutes: renew it through your server before using it.public IEnumerator EnsureFreshToken(string deviceId)
{
if (string.IsNullOrEmpty(Token) || TokenExpiresAt - DateTimeOffset.UtcNow < TimeSpan.FromMinutes(1))
yieldreturnInit(deviceId);
}
// 3. Pages: a fresh one-time code for every page you open, instead of the token.public IEnumerator OpenPage(string deviceId, string path, Action<string> open,
string redirect = null, bool allowGuest = true, string extra = null)
{
usingvar req = Post("/api/platform/link-code", $"{{\"deviceId\":\"{deviceId}\"}}");
yieldreturn req.SendWebRequest();
var linkCode = JsonUtility.FromJson<LinkCodeResponse>(req.downloadHandler.text).linkCode;
open(PageUrl(path, linkCode, redirect, allowGuest, extra));
}
publicstringPageUrl(string path, string linkCode, string redirect = null, bool allowGuest = true, string extra = null)
{
var url = $"{webBase.TrimEnd('/')}{path}?packageName={Uri.EscapeDataString(packageName)}&sdkType=js";
if (!string.IsNullOrEmpty(linkCode)) url += $"&linkCode={Uri.EscapeDataString(linkCode)}"; // was linkedTokenif (!string.IsNullOrEmpty(redirect)) url += $"&redirect={redirect}";
if (!allowGuest) url += "&allowGuest=false";
if (!string.IsNullOrEmpty(extra)) url += $"&{extra}";
return url;
}
توکن کلاینت لازم نیست: خود توکن تمدید مدرک است. این را از سرور خودتان صدا بزنید.
HTTP
POST {api}/auth/refresh
Content-Type: application/json
{ "refreshToken": "rt1.dkUoqaDDG8YB15KAQNO1q77FE…" }
→ 200 OK
{
"userId": 239236,
"isLinkedToAccount": true,
"accessToken": "eyJhbGciOiJIUzI1NiIs…",
"accessTokenExpiresAt": "2026-10-22T10:30:00+00:00",
"refreshToken": "rt1.Wn0QhZ…", // store THIS one — the old one no longer works
"refreshTokenExpiresAt": "2026-11-21T10:15:00+00:00"
}
→ 401 { "success": false, "error": "refresh_token_rotated" } // another request refreshed first: reload yours, retry once
→ 401 { "success": false, "error": "session_revoked" } // also invalid_refresh_token, refresh_token_expired,
// refresh_token_reused: get a new session
→ 403 { "success": false, "error": "account_suspended" }
POST {api}/v2/client/link-code/239236
Authorization: Bearer <client token>
→ 200 OK
{ "code": "q3Jc9yX2tL0…", "expiresAt": "2026-10-22T10:02:00+00:00" } // one page, once, ~2 minutes
→ 403 { "success": false, "error": "access denied" } // the user belongs to another game
روی پلتفرم توسعه تست کنید (هر شمارهای، کد 111111):
بازیکن جدید بازی را باز میکند: کلاینت توکن میگیرد و دیتابیس شما برایش توکن تمدید دارد.
اجرای دوباره: auth/refresh استفاده میشود (نه get-user-token) و توکن تمدید ذخیرهشده عوض شده.
دو اجرای همزمان یک بازیکن: هر دو موفقاند (یکی با تکرار بعد از rotated).
بازیکنی از قبل از مهاجرت یک بار بازی را باز میکند و توکن تمدید میگیرد.
همهی صفحههای بازینس با linkCode=… و بدونlinkedToken باز میشوند؛ صفحهی ورود، پروفایل، فروشگاه جایزه و صفحهی پیشنهاد همه کار میکنند.
ورود در صفحه با شمارهای که مال بازیکن دیگری از بازیکنان شماست، همچنان به آن بازیکن جابهجا میشود.
باز کردن بازی از اپ بازینس با ?linkCode= به حساب درست میرسد؛ همان لینک را دو بار باز کنید ← بار دوم کد نادیده گرفته میشود و بازی باز هم بالا میآید.
بعد از ۱۵ دقیقه بازی، موجودی سکه هنوز بارگذاری میشود (توکن از طریق سرور شما تمدید شده).
logout از صفحه نشست را میبندد؛ اجرای بعدی یک بازدیدکنندهی جدید است.
در لاگهای شما توکن تمدید نیست و هیچ توکنی در آدرسها نیست.
نکته
تا تاریخ پایان، اپ بازینس ?linkedToken= را هم میفرستد. وقتی بازی شما linkCode را میخواند، linkedToken را نادیده بگیرید. بعد از تاریخ پایان دیگر فرستاده نمیشود و توکنهای قدیمی هم کار نمیکنند.
نمونه کد
کد سرور
کد آمادهی سرور برای همهی قدمها (کلاینت پلتفرم، endpointهای خودتان، پرداخت گیتا کوین، پرداخت بازینس و برچسبهای کمپین) به TypeScript، C# و Python.
این نمونهها قدمهای API و راهنماها را پیاده میکنند. هر کدام به سه زبان TypeScript (Node)، C# (.NET) و Python آمده است؛ یک زبانه را انتخاب کنید و انتخابتان در همهی صفحهها به خاطر سپرده میشود. از Express، ASP.NET minimal API و FastAPI استفاده شده، ولی منطق در هر فریمورکی همین است. هر جا db… صدا زده شده، دیتابیس خودتان را بگذارید.
# --- Baziness platform: accounts, Gita coins, reward pages -------------------------------BAZINESS_PLATFORM_ENABLED=false# master switch: nothing shows until "true"BAZINESS_PLATFORM_USE_PRODUCTION=false# selects the default API + web URLs belowBAZINESS_CLIENT_ID=
BAZINESS_CLIENT_SECRET=
BAZINESS_PLATFORM_PACKAGE_NAME=com.yourstudio.yourgame
# Optional overrides (derived from USE_PRODUCTION when blank):# dev api https://platform-dev.karizmastudios.org/api web https://platform-web-dev.karizmastudios.org# prod api https://platform.karizmastudio.com/api web https://baziness.comBAZINESS_PLATFORM_API_URL=
BAZINESS_PLATFORM_WEB_URL=
BAZINESS_GAME_NAME=yourgame # ?game_name= of your pages on the platform (rewards, game offer)# Optional: the tutorial offer page (blank = {web}/game-offer?game_name=<BAZINESS_GAME_NAME>)
BAZINESS_GAME_OFFER_URL=
# --- Baziness Pay: real-money purchases ----------------------------------------------------BAZINESS_PAY_ENABLED=false# buy buttons stay disabled until "true"# dev https://pay-dev.karizmastudios.org prod https://pay.baziness.comBAZINESS_PAY_BASE_URL=https://pay-dev.karizmastudios.org
BAZINESS_PAY_PACKAGE_NAME=com.yourstudio.yourgame # the Pay app's package (pname) — may differ from BAZINESS_PLATFORM_PACKAGE_NAME
BAZINESS_SKU_PREFIX= # optional prefix: SKU = prefix + product idDEV_FAKE_BAZINESS_PAY=false# local development only (see the Pay page)
یک کلاس کوچک که قدمهای ۱ تا ۵ را انجام میدهد: با اولین استفاده توکن کلاینت را میگیرد، بعد از 401 دوباره میگیرد و شناسهی پرداخت را از کلید پرداخت میسازد.
GET /api/platform/config: بازینس روشن است یا نه، و صفحهها و API کجا هستند. اثر جانبی ندارد و هرگز کلید محرمانه را برنمیگرداند.
TypeScript (Node)
import express from"express";
import { platformClientConfig, platformEnabled, WEB_BASE } from"./platform/baziness";
constGAME_NAME = process.env.BAZINESS_GAME_NAME ?? "example"; // your game's name on the platformconstGAME_OFFER_URL = process.env.BAZINESS_GAME_OFFER_URL || `${WEB_BASE}/game-offer?game_name=${encodeURIComponent(GAME_NAME)}`;
const app = express();
app.use(express.json());
// Side-effect free: tells the client whether Baziness is on and where its pages live. Never the secret.
app.get("/api/platform/config", (_req, res) => {
res.json(platformEnabled()
? { enabled: true, ...platformClientConfig(), gameName: GAME_NAME, gameOfferUrl: GAME_OFFER_URL }
: { enabled: false });
});
C# (.NET)
// Side-effect free: tells the client whether Baziness is on and where its pages live. Never the secret.
app.MapGet("/api/platform/config", (PlatformSettings s, IConfiguration cfg) =>
{
if (!s.IsOn) return Results.Ok(new { enabled = false });
var gameName = cfg["Baziness:GameName"] ?? "example";
var gameOfferUrl = cfg["Baziness:GameOfferUrl"] is { Length: > 0 } custom
? custom
: $"{s.WebBase}/game-offer?game_name={Uri.EscapeDataString(gameName)}";
return Results.Ok(new { enabled = true, webBase = s.WebBase, apiBase = s.ApiBase, packageName = s.PackageName, gameName, gameOfferUrl });
});
Python
import os
from urllib.parse import quote
from fastapi import FastAPI
from baziness.platform import WEB_BASE, platform_client_config, platform_enabled
GAME_NAME = os.getenv("BAZINESS_GAME_NAME", "example") # your game's name on the platform
GAME_OFFER_URL = os.getenv("BAZINESS_GAME_OFFER_URL") orf"{WEB_BASE}/game-offer?game_name={quote(GAME_NAME)}"
app = FastAPI()
@app.get("/api/platform/config")defplatform_config():
"""Side-effect free: tells the client whether Baziness is on and where its pages live. Never the secret."""ifnot platform_enabled():
return {"enabled": False}
return {"enabled": True, **platform_client_config(), "gameName": GAME_NAME, "gameOfferUrl": GAME_OFFER_URL}
پرداختی که هیچوقت دو بار انجام نمیشود: کلید پرداخت transactionId را ثابت میکند و 400 یعنی قبلاً پرداخت شده.
TypeScript (Node)
// grants.ts — a payout that can never be paid twice.import { addCoin } from"./platform/baziness";
exportasyncfunctionpayTournamentReward(claimId: number, bazinessUserId: number, amount: number) {
try {
// Same claim → same key → same transactionId → the platform refuses a second credit.awaitaddCoin(bazinessUserId, amount, `reward_claim:${claimId}`, { source: "tournament", claimId });
} catch (err) {
// 400 = this transactionId was already credited (e.g. we crashed after the platform paid but// before we saved): the coins are there, so carry on instead of failing forever.if ((err as { status?: number }).status !== 400) throw err;
}
await db.claims.markPaid(claimId); // your own bookkeepingawait db.ledger.insert({ claimId, currency: "GITA_COIN", amount, external: true });
}
C# (.NET)
// Grants.cs — a payout that can never be paid twice.publicsealedclassGrants(BazinessPlatformClient platform, AppDb db)
{
publicasync Task PayTournamentRewardAsync(long claimId, long bazinessUserId, long amount)
{
try
{
// Same claim → same key → same transactionId → the platform refuses a second credit.await platform.AddCoinAsync(bazinessUserId, amount, $"reward_claim:{claimId}", new { source = "tournament", claimId });
}
catch (HttpRequestException e) when (e.StatusCode == System.Net.HttpStatusCode.BadRequest)
{
// 400 = already credited by an earlier attempt: the coins are there, carry on.
}
await db.Claims.MarkPaidAsync(claimId); // your own bookkeepingawait db.Ledger.InsertAsync(new LedgerRow(claimId, "GITA_COIN", amount, External: true));
}
}
Python
# grants.py — a payout that can never be paid twice.import requests
from baziness.platform import platform
defpay_tournament_reward(claim_id: int, baziness_user_id: int, amount: int) -> None:
try:
# Same claim → same key → same transactionId → the platform refuses a second credit.
platform.add_coin(baziness_user_id, amount, f"reward_claim:{claim_id}", {"source": "tournament", "claimId": claim_id})
except requests.HTTPError as err:
if err.response isNoneor err.response.status_code != 400:
raise# 400 = already credited by an earlier attempt: the coins are there, carry on.
db.claims.mark_paid(claim_id) # your own bookkeeping
db.ledger.insert(claim_id=claim_id, currency="GITA_COIN", amount=amount, external=True)
قانونهای گیتا کوین ← بازیکنانی که هنوز وارد نشدهاند: دریافت جایزه بازیکن وارد شده میخواهد، خرید پرداختشده هیچوقت شکست نمیخورد، و سکههای امانی هنگام ورود بازیکن به بازینس منتقل میشوند.
TypeScript (Node)
// grants.ts: Gita coins go to the platform; COIN/GEM stay local.exportasyncfunctiongrantCurrency(tx: Tx, change: WalletChange & { idempotencyKey: string },
opts: { requireLink: boolean; metaData?: object }) {
if (change.currency !== "GITA_COIN" || !platformEnabled() || change.amount < 0) {
returnapplyWalletChange(tx, change); // local wallet
}
const { baziness_user_id: bazinessUserId } = await tx.one("select baziness_user_id from users where id = $1", [change.userId]);
if (!bazinessUserId) {
if (opts.requireLink) thrownewApiError("PLATFORM_NOT_LINKED", "برای دریافت گیتا کوین وارد حساب بازینس شوید");
returnapplyWalletChange(tx, { ...change, note: "held until a Baziness account is linked" }); // escrow
}
try {
awaitaddCoin(bazinessUserId, change.amount, change.idempotencyKey, { source: change.type, ...opts.metaData });
} catch (err) {
thrownewApiError("PLATFORM_UNAVAILABLE", "ارتباط با بازینس برقرار نشد، دوباره تلاش کنید");
}
awaitrecordExternalCredit(tx, change); // ledger row, balance unchanged locally
}
/** Called right after a successful link (POST /api/platform/login or /token). */exportasyncfunctionflushGitaEscrow(userId: number) {
awaitwithTx(async (tx) => {
const user = await tx.one("select baziness_user_id from users where id = $1", [userId]);
const wallet = await tx.oneOrNone(
"select balance from user_wallets where user_id = $1 and currency = 'GITA_COIN' for update", [userId]);
if (!user.baziness_user_id || !wallet || wallet.balance <= 0) return;
const last = await tx.one("select max(id) as id from wallet_transactions where user_id = $1 and currency = 'GITA_COIN'", [userId]);
const key = `escrow:${userId}:${last.id ?? 0}`;
awaitaddCoin(user.baziness_user_id, wallet.balance, key, { source: "escrow" });
awaitapplyWalletChange(tx, { userId, currency: "GITA_COIN", amount: -wallet.balance,
type: "REWARD_SELECTION", idempotencyKey: key, note: "moved to the Baziness account" });
});
}
C# (.NET)
// Inside a service with: BazinessPlatformClient platform, PlatformSettings settings, IUserStore users, IWalletStore wallets.publicasync Task GrantAsync(DbTransaction tx, WalletChange change, bool requireLink)
{
if (change.Currency != "GITA_COIN" || !settings.IsOn || change.Amount < 0)
{ await wallets.ApplyAsync(tx, change); return; } // local walletlong? bazinessUserId = await users.GetBazinessUserIdAsync(tx, change.UserId);
if (bazinessUserId isnull)
{
if (requireLink) thrownew ApiException("PLATFORM_NOT_LINKED", "برای دریافت گیتا کوین وارد حساب بازینس شوید");
await wallets.ApplyAsync(tx, change with { Note = "held until a Baziness account is linked" }); // escrowreturn;
}
await platform.AddCoinAsync(bazinessUserId.Value, change.Amount, change.IdempotencyKey, new { source = change.Type });
await wallets.RecordExternalCreditAsync(tx, change);
}
publicasync Task FlushEscrowAsync(long userId)
{
awaitusingvar tx = await db.BeginTransactionAsync();
var bazinessUserId = await users.GetBazinessUserIdAsync(tx, userId);
var balance = await wallets.LockBalanceAsync(tx, userId, "GITA_COIN"); // SELECT … FOR UPDATEif (bazinessUserId isnull || balance <= 0) return;
var key = $"escrow:{userId}:{await wallets.LastTransactionIdAsync(tx, userId, "GITA_COIN")}";
await platform.AddCoinAsync(bazinessUserId.Value, balance, key, new { source = "escrow" });
await wallets.ApplyAsync(tx, new WalletChange(userId, "GITA_COIN", -balance, "REWARD_SELECTION", key));
await tx.CommitAsync();
}
Python
defgrant_currency(tx, change: dict, require_link: bool, meta: dict | None = None) -> None:
if change["currency"] != "GITA_COIN"ornot platform_enabled() or change["amount"] < 0:
return apply_wallet_change(tx, change) # local wallet
baziness_user_id = tx.scalar("select baziness_user_id from users where id = %s", [change["userId"]])
ifnot baziness_user_id:
if require_link:
raise ApiError("PLATFORM_NOT_LINKED", "برای دریافت گیتا کوین وارد حساب بازینس شوید")
return apply_wallet_change(tx, {**change, "note": "held until a Baziness account is linked"}) # escrow
platform.add_coin(baziness_user_id, change["amount"], change["idempotencyKey"], {"source": change["type"], **(meta or {})})
record_external_credit(tx, change)
defflush_gita_escrow(user_id: int) -> None:
with db.transaction() as tx:
baziness_user_id = tx.scalar("select baziness_user_id from users where id = %s", [user_id])
balance = tx.scalar("select balance from user_wallets where user_id = %s and currency = 'GITA_COIN' ""for update", [user_id]) or0ifnot baziness_user_id or balance <= 0:
return
last = tx.scalar("select max(id) from wallet_transactions where user_id = %s and currency = 'GITA_COIN'",
[user_id]) or0
key = f"escrow:{user_id}:{last}"
platform.add_coin(baziness_user_id, balance, key, {"source": "escrow"})
apply_wallet_change(tx, {"userId": user_id, "currency": "GITA_COIN", "amount": -balance,
"type": "REWARD_SELECTION", "idempotencyKey": key})
create table payments (
id bigserial primary key,
user_id bigintnot nullreferences users(id),
shop_product_id bigintnot nullreferences shop_products(id),
provider text not nulldefault'BAZINESS',
provider_sku text not null,
-- The Baziness payment token; unique so the same payment can never be fulfilled twice.
provider_transaction_id text,
amount bigintnot null,
currency_code text not null,
status text not nulldefault'CREATED'check (status in
('CREATED','REDIRECTED','PAID','VERIFIED','FAILED','CANCELLED','FULFILLED')),
reward_snapshot jsonb not null, -- what the player was promised when they clicked
failure_reason text,
created_at timestamptz not nulldefault now(),
redirected_at timestamptz,
verified_at timestamptz,
fulfilled_at timestamptz,
updated_at timestamptz not nulldefault now(),
unique (provider, provider_transaction_id)
);
create index payments_user on payments(user_id, created_at desc);
بازی نمونه یک جایگزین برای پرداخت بازینس دارد که همان پروتکل را حرف میزند، برای کار بدون دسترسی به درگاه. با این تنظیمات روشن میشود، و هرگز در محیط اصلی:
قانونهای لینکها و UTM. زبانهی اول سمت کلاینت است (خواندن برچسبها و پاک کردنشان از آدرس)؛ بقیه آنها را هنگام ساخت بازیکن در سرور ذخیره میکنند.
TypeScript (client)
// api/client.ts — the FIRST module the app imports.constUTM_KEY = "mygame_utm";
constTAG_KEY = "mygame_tag";
constUTM_FIELDS = ["source", "medium", "campaign", "content"] asconst;
typeUtm = Partial<Record<(typeofUTM_FIELDS)[number], string>>;
const clean = (raw: unknown): string | null => {
if (typeof raw !== "string") returnnull;
const v = raw.trim().toLowerCase().replace(/\s+/g, "_").slice(0, 64);
return/^[a-z0-9][a-z0-9._-]*$/.test(v) ? v : null; // ASCII only
};
exportconstFIRST_TOUCH_UTM: Utm = (() => {
letstored: Utm = {};
try { stored = JSON.parse(localStorage.getItem(UTM_KEY) ?? "{}"); } catch {}
try {
const query = newURLSearchParams(location.search);
const keys = [...query.keys()].filter((k) => k.toLowerCase().startsWith("utm_"));
constfromUrl: Utm = {};
for (const k of keys) {
const field = k.toLowerCase().slice(4) as keyof Utm;
const v = clean(query.get(k));
if (UTM_FIELDS.includes(field asnever) && v) fromUrl[field] = v;
}
if (keys.length) { // take them out of the address
keys.forEach((k) => query.delete(k));
const search = query.toString();
history.replaceState(history.state, "", `${location.pathname}${search ? `?${search}` : ""}${location.hash}`);
}
if (Object.keys(fromUrl).length && !Object.keys(stored).length) { // first touch wins
stored = fromUrl;
localStorage.setItem(UTM_KEY, JSON.stringify(stored));
}
} catch { /* storage blocked: the visit is simply untagged */ }
return stored;
})();
exportfunctionfirstTouchTag(): string | null {
try {
const fromUrl = clean(newURLSearchParams(location.search).get("tag"));
if (fromUrl && !localStorage.getItem(TAG_KEY)) localStorage.setItem(TAG_KEY, fromUrl);
returnlocalStorage.getItem(TAG_KEY) ?? fromUrl;
} catch { returnnull; }
}
// Sent with the device login AND with /api/platform/token:// { deviceId, tag: firstTouchTag(), utm: FIRST_TOUCH_UTM }
C# (server)
using System.Text.RegularExpressions;
// Save attribution ONLY when the account is created (first touch). Four separate columns.publicasync Task<User> EnsureUserByDeviceAsync(string deviceId, string? tag, UtmParams? utm)
{
conststring sql = """
insert into users (device_id, acq_tag, utm_source, utm_medium, utm_campaign, utm_content)
values (@deviceId, @tag, @source, @medium, @campaign, @content)
on conflict (device_id) do update set last_seen = now() -- existing account: attribution untouched
returning *;
""";
returnawait db.QuerySingleAsync<User>(sql, new {
deviceId, tag = Utm.Clean(tag),
source = Utm.Clean(utm?.Source), medium = Utm.Clean(utm?.Medium),
campaign = Utm.Clean(utm?.Campaign), content = Utm.Clean(utm?.Content),
});
}
publicstaticclassUtm
{
staticreadonly Regex Valid = new("^[a-z0-9][a-z0-9._-]*$", RegexOptions.Compiled);
publicstaticstring? Clean(string? raw)
{
if (raw isnull) returnnull;
var v = Regex.Replace(raw.Trim().ToLowerInvariant(), @"\s+", "_");
if (v.Length > 64) v = v[..64];
return Valid.IsMatch(v) ? v : null;
}
}
publicrecordUtmParams(string? Source, string? Medium, string? Campaign, string? Content);
Python (server)
import re
_VALID = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
defclean_utm(raw: str | None) -> str | None:
ifnotisinstance(raw, str):
returnNone
v = re.sub(r"\s+", "_", raw.strip().lower())[:64]
return v if _VALID.match(v) elseNonedefensure_user_by_device(device_id: str, tag: str | None, utm: dict | None):
"""Attribution is saved ONLY when the account is created (first touch)."""
utm = utm or {}
return db.fetch_one(
"""
insert into users (device_id, acq_tag, utm_source, utm_medium, utm_campaign, utm_content)
values (%s, %s, %s, %s, %s, %s)
on conflict (device_id) do update set last_seen = now()
returning *
""",
[device_id, clean_utm(tag), *(clean_utm(utm.get(k) or utm.get(f"utm_{k}"))
for k in ("source", "medium", "campaign", "content"))],
)
Schema (PostgreSQL)
alter table users
addcolumn if notexists acq_tag text,
addcolumn if notexists utm_source text,
addcolumn if notexists utm_medium text,
addcolumn if notexists utm_campaign text,
addcolumn if notexists utm_content text;
create index if notexists idx_users_utm_combo on users (utm_source, utm_medium, utm_campaign, utm_content)
where utm_source isnot nullor utm_medium isnot nullor utm_campaign isnot nullor utm_content isnot null;
کد آمادهی کلاینت (کنترلر پلتفرم، پیامهای صفحه، موجودی سکه، دکمهی جایزه، صفحهی پیشنهاد آموزش و پنجرهی خرید) برای وب، React، Cocos Creator و Unity.
این نمونهها سمت کلاینت راهنماها هستند. بیشترشان زبانههای TypeScript (وب)، React و C# (Unity) دارند، و در انتها یک نسخهی کامل Cocos Creator هست. وبویوی داخل بازی (شیت پایین) صفحهی خودش را دارد.
قلب کلاینت: توکن بازیکن را از سرور شما میگیرد، لینک همهی صفحهها را میسازد، صفحهها را باز میکند و انتخاب اجرای اول را در اسپلش انجام میدهد (ورود).
TypeScript (web)
// baziness.ts — the client controller.constTOKEN_KEY = "mygame_baziness_token";
constCHOICE_KEY = "mygame_platform_choice";
exportclassBazinessPlatform {
enabled = false;
isLinked = false;
privatetoken: string | null = null;
private webBase = "";
private apiBase = "";
private packageName = "";
privateloginGate: ((ok: boolean) =>void) | null = null;
private loginSucceeded = false;
onChange: (() =>void) | null = null;
constructor(privatesheet: BazinessSheet, privatedeviceId: string) {
sheet.onMessage = (m) =>this.handleMessage(m);
sheet.onClosed = () =>this.handleClosed();
}
getchoiceMade() { returnlocalStorage.getItem(CHOICE_KEY) === "1"; }
markChoiceMade() { localStorage.setItem(CHOICE_KEY, "1"); }
asyncloadConfig(): Promise<boolean> {
try {
const cfg = awaitfetch("/api/platform/config").then((r) => r.json());
this.enabled = cfg.enabled;
if (cfg.enabled) this.apply(cfg);
} catch { this.enabled = false; }
returnthis.enabled;
}
/** Resolve the token. Returns false when the page is about to reload into another account. */asyncinit(): Promise<boolean> {
// The Baziness app opens games with ?linkedToken=… — forward it once, then it is stripped on reload.const linkedToken = newURLSearchParams(location.search).get("linkedToken") || localStorage.getItem(TOKEN_KEY);
const resp = awaitfetch("/api/platform/token", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceId: this.deviceId, linkedToken }),
}).then((r) => r.json()).catch(() =>null);
if (!resp?.enabled) { this.enabled = false; returntrue; }
this.enabled = true;
this.apply(resp);
if (resp.switchToDeviceId && resp.switchToDeviceId !== this.deviceId) {
this.switchAccount(resp.switchToDeviceId);
returnfalse;
}
if (resp.token) {
this.token = resp.token;
this.isLinked = !!resp.isLinkedToAccount;
localStorage.setItem(TOKEN_KEY, resp.token);
}
this.onChange?.();
returntrue;
}
/** Splash "Sign in with Baziness": resolves true once a login/link succeeded, false if closed. */asyncopenLoginAndWait(): Promise<boolean> {
if (!this.token) awaitthis.init(); // the anonymous user's token becomes linkedTokenif (!this.enabled || !this.webBase) returnfalse;
returnnewPromise((resolve) => {
this.loginGate = resolve;
this.loginSucceeded = false;
this.sheet.open(this.pageUrl("/", { allowGuest: false }));
});
}
/** Settings → "Connect your account": the profile page. */openAccount() { this.sheet.open(this.pageUrl("/profile", { redirect: "profile", allowGuest: false })); }
pageUrl(path: string, o: { redirect?: string; allowGuest?: boolean; extra?: Record<string, string> } = {}) {
const u = newURL(this.webBase + path);
for (const [k, v] ofObject.entries(o.extra ?? {})) u.searchParams.set(k, v);
u.searchParams.set("packageName", this.packageName);
u.searchParams.set("sdkType", "js");
if (this.token) u.searchParams.set("linkedToken", this.token);
if (o.redirect) u.searchParams.set("redirect", o.redirect);
if (o.allowGuest === false) u.searchParams.set("allowGuest", "false");
return u.toString();
}
privateapply(c: { webBase?: string; apiBase?: string; packageName?: string }) {
this.webBase = c.webBase ?? ""; this.apiBase = c.apiBase ?? ""; this.packageName = c.packageName ?? "";
}
// handleMessage / handleClosed / switchAccount: see "Messages from the Baziness page" below.
}
React
// Splash.tsx — the first-time choice.exportfunctionSplash({ onReady }: { onReady: (me: Me) => void }) {
const [needsChoice, setNeedsChoice] = useState(false);
const choice = useRef<(() =>void) | null>(null);
const loggedIn = useRef(false);
useEffect(() => {
(async () => {
let me = (awaitauthenticate()).me; // 1. your own account firstif (await baziness.loadConfig()) { // 2. platform on?if (!baziness.choiceMade && !me.user.isBazinessLinked) {
setNeedsChoice(true); // 3. ask onceawaitnewPromise<void>((r) => (choice.current = r));
setNeedsChoice(false);
}
if (!(await baziness.init())) return; // 4. reloading into the linked accountif (loggedIn.current || baziness.isLinked !== me.user.isBazinessLinked) {
me = await api<Me>("GET", "/api/me"); // 5. show the linked account
}
}
onReady(me);
})();
}, [onReady]);
constchooseGuest = () => { baziness.markChoiceMade(); choice.current?.(); };
constchooseLogin = async () => {
await baziness.init();
if (!(await baziness.openLoginAndWait())) return; // closed without logging in → keep the buttons
loggedIn.current = true;
choice.current?.();
};
return needsChoice ? (
<divclassName="splash-choice"><buttonclassName="btn btn-green"onClick={chooseLogin}>ورود با حساب بازینس</button><buttonclassName="btn btn-ghost"onClick={chooseGuest}>ادامه به عنوان مهمان</button></div>
) : <ProgressBar />;
}
C# (Unity)
// BazinessLogin.cs — the same protocol from a Unity client. Your SERVER still does every// platform call; Unity only talks to your server and shows the page in a WebView.using System;
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;
publicclassBazinessLogin : MonoBehaviour
{
conststring TokenKey = "baziness_token", ChoiceKey = "baziness_choice";
[SerializeField] string serverUrl = "https://api.yourgame.com";
[SerializeField] BazinessSheet sheet; // see the Bottom sheet pagepublicbool Enabled { get; privateset; }
publicbool IsLinked { get; privateset; }
publicstring Token { get; privateset; }
publicstring PackageName => packageName;
publicstring GameOfferUrl { get; privateset; } // from /api/platform/tokenstring webBase, apiBase, packageName;
Action<bool> loginGate;
[Serializable] classTokenResponse {
publicbool enabled; publicstring webBase, apiBase, packageName, token, switchToDeviceId;
publiclong bazinessUserId; publicbool isLinkedToAccount;
}
public IEnumerator Init(string deviceId)
{
var body = $"{{\"deviceId\":\"{deviceId}\",\"linkedToken\":{Quote(PlayerPrefs.GetString(TokenKey, null))}}}";
usingvar req = new UnityWebRequest($"{serverUrl}/api/platform/token", "POST");
req.uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(body));
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json");
yieldreturn req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success) { Enabled = false; yieldbreak; }
var r = JsonUtility.FromJson<TokenResponse>(req.downloadHandler.text);
Enabled = r.enabled;
if (!r.enabled) yieldbreak;
(webBase, apiBase, packageName) = (r.webBase, r.apiBase, r.packageName);
if (!string.IsNullOrEmpty(r.switchToDeviceId) && r.switchToDeviceId != deviceId)
{
DeviceIdentity.Set(r.switchToDeviceId); // adopt the other account …
PlayerPrefs.DeleteKey(TokenKey);
GameBoot.Restart(); // … and boot again as that playeryieldbreak;
}
if (!string.IsNullOrEmpty(r.token)) { Token = r.token; IsLinked = r.isLinkedToAccount; PlayerPrefs.SetString(TokenKey, r.token); }
}
publicvoidOpenLogin(Action<bool> done)
{
loginGate = done;
sheet.Open(PageUrl("/", allowGuest: false), OnRawPageMessage, onClosed: () => { loginGate?.Invoke(IsLinked); loginGate = null; });
}
publicstringPageUrl(string path, string redirect = null, bool allowGuest = true, string extra = null)
{
var url = $"{webBase}{path}?{extra}{(extra != null ? "&" : "")}packageName={Uri.EscapeDataString(packageName)}&sdkType=js";
if (!string.IsNullOrEmpty(Token)) url += $"&linkedToken={Uri.EscapeDataString(Token)}";
if (redirect != null) url += $"&redirect={redirect}";
if (!allowGuest) url += "&allowGuest=false";
return url;
}
publicvoidOnRawPageMessage(string json) { /* see "Messages from the Baziness page" below */ }
staticstringQuote(string s) => string.IsNullOrEmpty(s) ? "null" : $"\"{s}\"";
}
// Inside BazinessPlatform (continued).const extractToken = (data: unknown): string | undefined =>typeof data === "string" ? data
: data && typeof (data as { token?: unknown }).token === "string" ? (data as { token: string }).token
: undefined;
handleMessage(msg: { type: string; data?: unknown }) {
const token = extractToken(msg.data);
switch (msg.type) {
case"close":
this.sheet.close();
break;
case"login":
if (token) voidthis.applyNewLogin(token); // signed into an account → switch or linkelsevoidthis.linkedInPlace(); // linked onto the current playerbreak;
case"token":
break; // silent handshake — NOT a logincase"logout":
this.sheet.close();
localStorage.clear(); // identity, token, choice, tutorial flags…this.reloadClean(); // next boot = brand-new visitorbreak;
}
}
privateasyncapplyNewLogin(token: string) {
const result = awaitfetch("/api/platform/login", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceId: this.deviceId, token }),
}).then((r) => r.json());
if (result.switchToDeviceId && result.switchToDeviceId !== this.deviceId) {
this.markChoiceMade();
this.switchAccount(result.switchToDeviceId); // reloadsreturn;
}
if (result.ok) {
localStorage.setItem(TOKEN_KEY, token);
awaitthis.init(); // fresh token + linked flagthis.loginSucceeded = true;
this.markChoiceMade();
this.sheet.close(); // handleClosed resolves the login gate
}
// on failure keep the page open so the player can retry
}
privateasynclinkedInPlace() {
this.isLinked = true;
this.loginSucceeded = true;
this.markChoiceMade();
if (this.loginGate) { awaitthis.init(); this.sheet.close(); }
this.onChange?.();
}
privatehandleClosed() {
const gate = this.loginGate;
this.loginGate = null;
gate?.(this.loginSucceeded); // true only if a login actually happenedthis.onChange?.(); // coin balance / link state may have changed
}
privateswitchAccount(deviceId: string) {
setDeviceId(deviceId); // your own identity storelocalStorage.removeItem(TOKEN_KEY);
this.reloadClean();
}
privatereloadClean() {
const url = newURL(location.href);
url.searchParams.delete("linkedToken"); // never re-apply a stale token
url.searchParams.delete("device"); // a ?device= test override would undo the switch
location.replace(url.toString());
}
React
// BazinessSheet.tsx — listening for page messages (only from the page's own origin).functionparse(data: unknown): { type: string; data?: unknown } | null {
let value = data;
if (typeof data === "string") {
try { value = JSON.parse(data); } catch { returnnull; }
}
return value && typeof value === "object" && typeof (value as { type?: unknown }).type === "string"
? (value as { type: string; data?: unknown })
: null;
}
useEffect(() => {
if (!url) return;
const origin = newURL(url).origin;
constonMessage = (event: MessageEvent) => {
if (event.origin !== origin) return; // ignore HMR, extensions, other framesconst msg = parse(event.data);
if (msg) baziness.handleMessage(msg);
};
window.addEventListener("message", onMessage);
return() =>window.removeEventListener("message", onMessage);
}, [url]);
C# (Unity)
// Called by BazinessSheet with the raw JSON the page posted (see the Bottom sheet page).// Uses Newtonsoft.Json (com.unity.nuget.newtonsoft-json) because `data` can be a string OR an object.using Newtonsoft.Json.Linq;
publicvoidOnRawPageMessage(string json)
{
JObject msg;
try { msg = JObject.Parse(json); } catch { return; }
var type = (string)msg["type"];
var data = msg["data"];
string token = data?.Type == JTokenType.String ? (string)data : (string)data?["token"];
switch (type)
{
case"close": sheet.Close(); break;
case"login":
if (!string.IsNullOrEmpty(token)) StartCoroutine(ApplyNewLogin(token)); // POST /api/platform/loginelse { IsLinked = true; StartCoroutine(Init(DeviceIdentity.Get())); sheet.Close(); }
break;
case"token": break; // silent handshake — NOT a logincase"logout":
sheet.Close();
PlayerPrefs.DeleteAll(); // forget identity, token and choice
GameBoot.Restart(); // boot again as a new visitorbreak;
}
}
// Inside BazinessPlatform: the player's Gita coin balance, straight from the platform. null = unknown.asyncgetCoin(): Promise<number | null> {
if (!this.enabled || !this.token || !this.apiBase) returnnull;
try {
const res = awaitfetch(`${this.apiBase}/user/get-coin`, {
headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json" },
});
if (!res.ok) returnnull;
const json = (await res.json()) as { amount?: number };
returntypeof json.amount === "number" ? json.amount : null;
} catch {
returnnull;
}
}
// Wiring: refresh on every relevant moment.
baziness.onChange = () =>void baziness.getCoin().then(renderCoinChip); // sheet closed, login, linkwindow.addEventListener("focus", () =>void baziness.getCoin().then(renderCoinChip));
React
// session.tsx — keep the external balance in context and refresh it on the right events.const [gita, setGita] = useState<number | null>(null);
const refreshGita = useCallback(async () => {
if (baziness.enabled) setGita(await baziness.getCoin());
}, []);
useEffect(() => {
baziness.onChange = () =>voidrefreshGita(); // fires when the sheet closes / login changesvoidrefreshGita();
constonFocus = () => voidrefreshGita();
window.addEventListener("focus", onFocus);
return() =>window.removeEventListener("focus", onFocus);
}, [refreshGita]);
// Anywhere in the UI:<spanclassName="gita-chip"><CurrencyIconcurrency="GITA_COIN" /><bdi>{gita === null ? "…" : faNum(gita)}</bdi></span>
C# (Unity)
using System;
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;
[Serializable] classCoinResponse { publiclong amount; }
// apiBase and the user token come from /api/platform/token (see the Login page).public IEnumerator GetCoin(Action<long?> done)
{
if (!Enabled || string.IsNullOrEmpty(Token)) { done(null); yieldbreak; }
usingvar req = UnityWebRequest.Get($"{apiBase}/user/get-coin");
req.SetRequestHeader("Authorization", $"Bearer {Token}");
req.SetRequestHeader("Content-Type", "application/json");
yieldreturn req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success) { done(null); yieldbreak; }
done(JsonUtility.FromJson<CoinResponse>(req.downloadHandler.text).amount);
}
// Refresh when the app regains focus and when the Baziness sheet closes.voidOnApplicationFocus(bool focus) { if (focus) StartCoroutine(GetCoin(UpdateCoinChip)); }
// Inside BazinessPlatform. GAME_NAME comes from /api/platform/config (or a constant).openRewardSelection(): void {
if (!this.enabled || !this.webBase) return;
this.sheet.open(
this.pageUrl("/shop", { redirect: "shop", extra: { game_name: this.gameName } }),
BAZINESS_BOTTOM_BAR_CROP_PX, // optional: hide the platform's own tab bar (see below)
);
}
// The lobby banner
rewardBanner.onclick = () => {
if (baziness.enabled) baziness.openRewardSelection();
elsetoast("انتخاب جایزه بعد از اتصال به بازینس فعال میشود");
};
React
// Home.tsx — the «انتخاب جایزه» banner with the live balance.const { gita } = useSession(); // from get-coin, see the Gita coins pageconstopenRewards = () => {
if (baziness.enabled) baziness.openRewardSelection();
elsetoast("انتخاب جایزه بعد از اتصال به بازینس فعال میشود", "info");
};
return (
<buttonclassName="hero-banner reward-banner"onClick={openRewards}><spanclassName="hero-text"><h2>انتخاب جایزه</h2><spanclassName="hero-sub">گیتا کوینهایت را با جایزهی واقعی عوض کن</span><spanclassName="gita-chip"><CurrencyIconcurrency="GITA_COIN"size={30} /><bdiclassName="num">{gita === null ? "…" : faNum(gita)}</bdi></span></span><imgclassName="hero-art"src={art("ui/banner-rewards")} alt="" /></button>
);
C# (Unity)
// RewardBanner.cs — attach to the lobby button.publicclassRewardBanner : MonoBehaviour
{
[SerializeField] BazinessLogin platform;
[SerializeField] BazinessSheet sheet;
[SerializeField] TMPro.TMP_Text balanceLabel;
[SerializeField] string gameName = "example";
voidOnEnable() => StartCoroutine(platform.GetCoin(ShowBalance));
publicvoidOnClick()
{
if (!platform.Enabled) { Toast.Show("انتخاب جایزه بعد از اتصال به بازینس فعال میشود"); return; }
var url = platform.PageUrl("/shop", redirect: "shop", extra: $"game_name={Uri.EscapeDataString(gameName)}");
sheet.Open(url, platform.OnRawPageMessage, onClosed: () => StartCoroutine(platform.GetCoin(ShowBalance)));
}
voidShowBalance(long? amount) => balanceLabel.text = amount?.ToString("N0") ?? "…";
}
روش صفحهی جایزه: فریم را بلندتر از نما کنید و بگذارید نما آن را برش بدهد.
TypeScript (web)
/** Pixels cropped off the bottom of the embedded page. 0 = show the platform's tab bar. */exportconstBAZINESS_BOTTOM_BAR_CROP_PX = 0;
// in BazinessSheet.open(url, cropBottomPx):this.frame.style.marginBottom = cropBottomPx > 0 ? `${-cropBottomPx}px` : "";
تب را داخل همان ضربه باز کنید، خرید را در سرور شروع کنید، تب را به صفحهی پرداخت ببرید و وقتی بازیکن برگشت بررسی کنید (پرداخت بازینس).
TypeScript (web)
// purchase.ts — the purchase popup.exportfunctionbuy(productId: string, ui: PurchaseUi) {
const tab = window.open("about:blank", "_blank"); // 1. synchronously, no "noopener"letpaymentId: string | null = null;
let started = false;
let verifying = false;
constverify = async (manual: boolean) => {
if (!started || verifying || !paymentId) return;
verifying = true;
ui.showChecking();
try {
const r = awaitapi("POST", "/api/shop/purchase/verify", { paymentId });
if (r.status === "FULFILLED") { cleanup(); ui.success(r.granted); voidrefreshWallets(); return; }
if (manual) ui.toast("پرداختی پیدا نشد. اگر پرداخت کردهای چند لحظه بعد دوباره بررسی کن.");
ui.showWaiting();
} catch (err) {
if (manual) ui.error(err);
ui.showWaiting();
} finally { verifying = false; }
};
constonFocus = () => voidverify(false);
constonVisible = () => document.visibilityState === "visible" && voidverify(false);
constcleanup = () => { removeEventListener("focus", onFocus); document.removeEventListener("visibilitychange", onVisible); };
addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVisible);
ui.showOpening(() =>verify(true) /* «پرداخت کردم» */, () => { voidverify(false); cleanup(); } /* close */);
(async () => {
try {
const r = awaitapi("POST", "/api/shop/purchase/start", { productId }); // 2.
paymentId = r.paymentId;
if (tab && !tab.closed) tab.location.href = r.url;
elsewindow.open(r.url, "_blank", "noopener");
started = true;
ui.showWaiting(); // 3.
} catch (err) {
tab?.close(); cleanup(); ui.error(err);
}
})();
}
React
// Shop.tsx + PurchaseModal.tsx: open the tab in the click, then mount the modal.exportfunctionopenPayTab(): Window | null {
returnwindow.open("about:blank", "_blank"); // must run inside the click handler
}
// <button onClick={() => setBuying({ product, payTab: openPayTab() })}>…</button>exportfunctionPurchaseModal({ product, payTab, onClose }: Props) {
const { setWallets, toast, refresh } = useSession();
const [phase, setPhase] = useState<"opening" | "waiting" | "checking" | "done">("opening");
const paymentId = useRef<string | null>(null);
const started = useRef(false);
const verifying = useRef(false);
const verify = useCallback(async (manual: boolean) => {
if (!paymentId.current || verifying.current || !started.current) return;
verifying.current = true;
setPhase("checking");
try {
const res = await api<VerifyPurchaseResponse>("POST", "/api/shop/purchase/verify", { paymentId: paymentId.current });
setWallets(res.wallets);
if (res.status === "FULFILLED") { setPhase("done"); toast("خرید با موفقیت انجام شد", "success"); voidrefresh(); return; }
if (manual) toast("پرداختی پیدا نشد. اگر پرداخت کردهای چند لحظه بعد دوباره بررسی کن.", "info");
setPhase("waiting");
} catch (err) {
if (manual) toast(errorMessage(err), "error");
setPhase("waiting");
} finally { verifying.current = false; }
}, [refresh, setWallets, toast]);
useEffect(() => { // start once
(async () => {
try {
const res = await api<StartPurchaseResponse>("POST", "/api/shop/purchase/start", { productId: product.id });
paymentId.current = res.paymentId;
if (payTab && !payTab.closed) payTab.location.href = res.url;
elsewindow.open(res.url, "_blank", "noopener");
started.current = true;
setPhase("waiting");
} catch (err) { payTab?.close(); toast(errorMessage(err), "error"); onClose(); }
})();
}, []);
useEffect(() => { // verify when the player comes backconstonFocus = () => voidverify(false);
constonVisible = () => document.visibilityState === "visible" && voidverify(false);
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVisible);
return() => { window.removeEventListener("focus", onFocus); document.removeEventListener("visibilitychange", onVisible); };
}, [verify]);
constclose = () => { if (phase !== "done" && paymentId.current) voidverify(false); onClose(); };
return (
<ModalopenonClose={close}><h3>{product.title}</h3>
{phase === "done" ? <p>به حسابت اضافه شد ✓</p>
: phase === "waiting" ? (<><p>صفحهی پرداخت بازینس باز شد. بعد از پرداخت به بازی برگرد.</p><buttononClick={() => void verify(true)}>پرداخت کردم</button></>)
: <Spinner />}
</Modal>
);
}
C# (Unity)
// PurchaseFlow.cs — on mobile the payment page opens in the system browser, so there is no// popup blocker. Verify when the app regains focus, plus a manual «پرداخت کردم» button.publicclassPurchaseFlow : MonoBehaviour
{
[SerializeField] GameApi api; // your HTTP wrapper around /api/shop/purchase/*string paymentId;
bool started, verifying;
public IEnumerator Buy(string productId)
{
StartResponse r = null;
yieldreturn api.Post<StartResponse>("/api/shop/purchase/start", new { productId }, x => r = x);
if (r == null) { Toast.Show("خطا در اتصال به درگاه"); yieldbreak; }
paymentId = r.paymentId;
Application.OpenURL(r.url); // {base}/buy?sku&uid&pname, built by YOUR server
started = true;
PurchasePanel.ShowWaiting(onPaid: () => StartCoroutine(Verify(manual: true)));
}
voidOnApplicationFocus(bool focus)
{
if (focus && started) StartCoroutine(Verify(manual: false)); // back from the browser
}
IEnumerator Verify(bool manual)
{
if (verifying || string.IsNullOrEmpty(paymentId)) yieldbreak;
verifying = true;
VerifyResponse v = null;
yieldreturn api.Post<VerifyResponse>("/api/shop/purchase/verify", new { paymentId }, x => v = x);
verifying = false;
if (v?.status == "FULFILLED") { started = false; PurchasePanel.Success(v.granted); Wallet.Set(v.wallets); }
elseif (manual) Toast.Show("پرداختی پیدا نشد. اگر پرداخت کردهای چند لحظه بعد دوباره بررسی کن.");
}
[System.Serializable] classStartResponse { publicstring paymentId, url; }
[System.Serializable] classVerifyResponse { publicstring status; public Grant[] granted; public Wallets wallets; }
}
اتصالهای زمان بالا آمدن: linkedToken از آدرس، /api/platform/token، ورود، خروج، جابهجایی حساب
پریفب یک WebView، یک نود لایهی بارگذاری، یک دکمهی بستن و یک Animation با کلیپهای Open و Close (لغزیدن به بالا و پایین) لازم دارد. در بیلد وب، WebView یک iframe است، پس پیامهای صفحه به شکل رویداد message روی window میرسند.
BazinessSDK.ts
// BazinessSDK.ts — a persistent component (put it on a node in the first scene).// Opens Baziness pages in a WebView prefab, routes their messages and reads the Gita coin balance.import { _decorator, Component, director, find, instantiate, Prefab, EventTarget } from"cc";
import { BazinessWebView } from"./BazinessWebView";
const { ccclass, property } = _decorator;
exportconst bazinessEvents = newEventTarget(); // "login" | "logout" | "closed"constWEB = { dev: "https://platform-web-dev.karizmastudios.org", prod: "https://baziness.com" };
constAPI = { dev: "https://platform-dev.karizmastudios.org/api", prod: "https://platform.karizmastudio.com/api" };
@ccclass("BazinessSDK")
exportclassBazinessSDKextendsComponent {
@property(Prefab) webViewPrefab: Prefab = null!;
staticinstance: BazinessSDK;
static packageName = "com.yourstudio.yourgame";
static devMode = true;
statictoken: string | null = null; // the player's user token, from YOUR serverstatic isLinked = false;
privateview: BazinessWebView | null = null;
onLoad() {
BazinessSDK.instance = this;
director.addPersistRootNode(this.node);
}
/** Call after your server answered /api/platform/token. */staticinitialize(token: string, isLinked: boolean) {
this.token = token;
this.isLinked = isLinked;
}
staticopenLogin() {
this.instance.open(`${this.web()}/`, { allowGuest: false });
}
staticopenProfile() {
this.instance.open(`${this.web()}/profile`, { redirect: "profile", allowGuest: false });
}
staticopenRewards(gameName: string) {
this.instance.open(`${this.web()}/shop`, { redirect: "shop", extra: { game_name: gameName } });
}
/** The Gita coin balance, straight from the platform with the player's own token. */staticasyncgetCoin(): Promise<number | null> {
if (!this.token) returnnull;
try {
const res = awaitfetch(`${this.devMode ? API.dev : API.prod}/user/get-coin`, {
headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json" },
});
return res.ok ? ((await res.json()).amount ?? null) : null;
} catch {
returnnull;
}
}
privatestaticweb() {
returnthis.devMode ? WEB.dev : WEB.prod;
}
privateopen(base: string, o: { redirect?: string; allowGuest?: boolean; extra?: Record<string, string> } = {}) {
if (this.view) return; // one page at a timeconst q = newURLSearchParams(o.extra);
q.set("packageName", BazinessSDK.packageName);
q.set("sdkType", "js"); // the page posts close / login / logout / tokenif (BazinessSDK.token) q.set("linkedToken", BazinessSDK.token);
if (o.redirect) q.set("redirect", o.redirect);
if (o.allowGuest === false) q.set("allowGuest", "false");
const node = instantiate(this.webViewPrefab);
node.setParent(find("Canvas"));
this.view = node.getComponent(BazinessWebView)!;
this.view.onMessage = (m) =>this.onMessage(m);
this.view.onClosed = () => {
this.view = null;
bazinessEvents.emit("closed");
};
this.view.open(`${base}?${q}`);
}
privateonMessage(msg: { type: string; data?: unknown }) {
const token = typeof msg.data === "string" ? msg.data : (msg.dataas { token?: string } | undefined)?.token;
switch (msg.type) {
case"close":
this.view?.close();
break;
case"login":
if (token) {
this.view?.close(true);
bazinessEvents.emit("login", token); // → POST /api/platform/login on your server
} else {
BazinessSDK.isLinked = true; // linked in place
}
break;
case"logout":
this.view?.close(true);
bazinessEvents.emit("logout");
break;
case"token":
break; // the page's silent handshake — not a login
}
}
}
BazinessWebView.ts
// BazinessWebView.ts — the component on the WebView prefab (WebView + loading overlay + close button).import { _decorator, Animation, Button, Component, Node, WebView } from"cc";
const { ccclass, property } = _decorator;
@ccclass("BazinessWebView")
exportclassBazinessWebViewextendsComponent {
@property(WebView) webView: WebView = null!;
@property(Node) loadingOverlay: Node = null!;
@property(Button) closeButton: Button = null!;
@property(Animation) animation: Animation = null!; // "Open" slides up, "Close" slides downonMessage: ((m: { type: string; data?: unknown }) =>void) | null = null;
onClosed: (() =>void) | null = null;
private origin = "";
privatecloseTimer: ReturnType<typeofsetTimeout> | null = null;
private listener = (e: MessageEvent) =>this.onWindowMessage(e);
open(url: string) {
this.origin = newURL(url).origin;
this.webView.node.active = false;
this.closeButton.node.active = false;
// Our own close button appears after a few seconds: a way out even if the page never loads.this.closeTimer = setTimeout(() => {
if (this.closeButton?.isValid) this.closeButton.node.active = true;
}, 3000);
this.closeButton.node.on(Button.EventType.CLICK, () =>this.close(), this);
this.webView.node.on(WebView.EventType.LOADED, () => {
this.loadingOverlay.active = false;
this.webView.node.active = true;
}, this);
this.webView.node.on(WebView.EventType.ERROR, () =>this.close(), this);
window.addEventListener("message", this.listener); // on web builds the WebView is an iframethis.webView.url = url;
this.animation.play("Open");
}
close(immediately = false) {
window.removeEventListener("message", this.listener);
if (this.closeTimer) clearTimeout(this.closeTimer);
if (!immediately) this.animation.play("Close");
setTimeout(() => {
this.onClosed?.();
if (this.node?.isValid) this.node.destroy();
}, immediately ? 0 : 500);
}
privateonWindowMessage(e: MessageEvent) {
if (e.origin !== this.origin) return; // only the page we openedtry {
const msg = typeof e.data === "string" ? JSON.parse(e.data) : e.data;
if (msg && typeof msg.type === "string") this.onMessage?.(msg);
} catch {
/* not a Baziness message */
}
}
}
BazinessController.ts
// BazinessController.ts — boot-time wiring between your server, the SDK and the page messages.import { BazinessSDK, bazinessEvents } from"./BazinessSDK";
import { GameApi } from"./GameApi"; // your HTTP client for /api/*import { LocalData } from"./LocalData"; // your localStorage wrapperexportclassBazinessController {
/** Returns true when it started an account switch or a reset — stop your own boot then. */staticasyncinitialize(): Promise<boolean> {
// The Baziness app opens games with ?linkedToken=… . If it doesn't match the token this device// already holds, a different Baziness account is arriving: start clean before going on.const fromUrl = newURLSearchParams(location.search).get("linkedToken");
const saved = LocalData.get("baziness_token");
if (fromUrl && saved && fromUrl !== saved) {
this.resetAndReload();
returntrue;
}
const resp = awaitGameApi.post("/api/platform/token", { linkedToken: fromUrl ?? saved });
if (!resp.enabled) returnfalse; // platform off: no Baziness UIif (resp.switchToDeviceId) {
this.switchTo(resp.switchToDeviceId);
returntrue;
}
if (resp.token) {
LocalData.set("baziness_token", resp.token);
BazinessSDK.initialize(resp.token, resp.isLinkedToAccount);
}
bazinessEvents.on("login", async (token: string) => {
const r = awaitGameApi.post("/api/platform/login", { token }); // the server validates itif (r.switchToDeviceId) this.switchTo(r.switchToDeviceId);
elseif (r.ok) {
LocalData.set("baziness_token", token);
awaitthis.initialize();
}
});
bazinessEvents.on("logout", () =>this.resetAndReload());
returnfalse;
}
privatestaticswitchTo(deviceId: string) {
LocalData.clear();
LocalData.set("device_id", deviceId);
this.reload();
}
privatestaticresetAndReload() {
LocalData.clear(); // the next boot is a brand-new visitorthis.reload();
}
privatestaticreload() {
const url = newURL(location.href);
url.searchParams.delete("linkedToken");
location.replace(url.toString()); // or reload your loading scene
}
}
نمونه کد
شیت پایین (وبویوی داخل بازی)
شیت کشویی که همهی صفحههای بازینس را داخل بازی نشان میدهد. از پایین بالا میآید، با کشیدن، زدن بیرون یا دکمهی خود صفحه بسته میشود و پیامهای صفحه را به بازی میرساند.
همهی صفحههای بازینس (ورود، پروفایل، فروشگاه جایزه، پیشنهاد بازی) داخل بازی باز میشوند، نه در تب جدید. ظرف پیشنهادی یک شیت پایین است: روی بازی تیرهشده از پایین بالا میآید، کمی پایینتر از بالای صفحه میایستد و لبهی بالایی گرد با یک دستگیره دارد. بازیکن به سه روش میتواند آن را ببندد و خود صفحه هم میتواند.
امتحانش کنید:
دستگیره را به پایین بکشید، روی ناحیهی تیره بزنید یا دکمهی بستن صفحه را بزنید.
باز شدن: شیت را زیر صفحه بکشید و در فریم بعد به بالا متحرک کنید (حدود ۳۲۰ میلیثانیه، ease-out).
سه راه بستن:
کشیدن دستگیره به پایین بیشتر از min(150px, 28% ارتفاع شیت)؛ کشیدن کوتاهتر شیت را به جایش برمیگرداند
زدن روی ناحیهی تیرهی بالای شیت
صفحه پیام { "type": "close" } بفرستد
فقط دستگیره کشیدنی است. صفحه لمسهای خودش را میبلعد، پس کشیدنی که داخل صفحه شروع شود شیت را تکان نمیدهد.
روی iframe sandbox نگذارید. صفحهی ورود به کوکیها، پنجرههای بازشو (برای کد) و redirect خودش نیاز دارد. clipboard-read; clipboard-write; payment را مجاز کنید.
تا بارگذاری صفحه یک spinner نشان دهید.
پیامها را فقط از origin خود صفحه بپذیرید. هم JSON رشتهای و هم شیء ساده را بخوانید و type رشتهای را الزامی کنید.
تمیز ببندید: به پایین متحرک کنید، بعد فقط یک بار جمعش کنید: فریم را به about:blank ببرید، گوش دادن به پیامها را قطع کنید و بعد «بسته شد» را اعلام کنید. اگر در این فاصله شیت دوباره باز شده، کاری نکنید.
بالای همهچیز باشد، اسپلش هم: صفحهی ورود ممکن است از اسپلش باز شود.
اندازهاش مثل ستون بازی باشد: در گوشی تمامعرض، در صفحههای پهن حداکثر حدود ۵۶۰ پیکسل و وسطچین.
کامپوننت BazinessWebView در کد کلاینت ← Cocos Creator همین کار را با یک پریفب WebView انجام میدهد: لایهی بارگذاری تا لود شدن صفحه، دکمهی بستنی که بعد از چند ثانیه ظاهر میشود، و انیمیشنهای لغزیدن Open / Close.
Unity وبویوی داخلی ندارد و به پلاگین نیاز دارید. این طرح از gree/unity-webview استفاده میکند: صفحه ۸٪ پایینتر از بالای صفحه شروع میشود، دستگیره یک المان UI یونیتی بالای آن است و پیامهای صفحه با یک پل کوچک جاوااسکریپتی به C# میرسند.
C# (Unity)
// BazinessSheet.cs — a bottom sheet around a WebView plugin (gree/unity-webview).using System;
using UnityEngine;
using UnityEngine.EventSystems;
publicclassBazinessSheet : MonoBehaviour, IDragHandler, IEndDragHandler// put this on the grabber UI
{
[SerializeField] RectTransform grabber; // a 26px bar drawn by Unity above the web view
[SerializeField] GameObject dimmer; // full-screen button behind; OnClick → Close()
WebViewObject web;
Action<string> onMessage;
Action onClosed;
float topMargin, drag;
publicvoidOpen(string url, Action<string> onMessage, Action onClosed = null)
{
this.onMessage = onMessage;
this.onClosed = onClosed;
topMargin = Screen.height * 0.08f; // the sheet stops 8% short of the top
drag = 0;
web = new GameObject("BazinessWebView").AddComponent<WebViewObject>();
web.Init(
cb: msg => this.onMessage?.Invoke(msg), // messages forwarded by the bridge below
ld: _ => web.EvaluateJS(
"window.addEventListener('message',function(e){" +
"Unity.call(typeof e.data==='string'?e.data:JSON.stringify(e.data));});"));
Layout();
web.LoadURL(url);
web.SetVisibility(true);
dimmer.SetActive(true);
grabber.gameObject.SetActive(true);
}
publicvoidOnDrag(PointerEventData e) { drag = Mathf.Max(0, drag - e.delta.y); Layout(); }
publicvoidOnEndDrag(PointerEventData e)
{
if (drag > Mathf.Min(150, (Screen.height - topMargin) * 0.28f)) Close();
else { drag = 0; Layout(); } // spring back
}
publicvoidClose()
{
if (web == null) return;
Destroy(web.gameObject);
web = null;
dimmer.SetActive(false);
grabber.gameObject.SetActive(false);
onClosed?.Invoke();
}
voidLayout()
{
int top = (int)(topMargin + drag + grabber.rect.height);
web.SetMargins(0, top, 0, 0);
grabber.anchoredPosition = new Vector2(0, -(topMargin + drag));
}
}
یک مسابقهی سنگ، کاغذ، قیچی «بهترین از ۵ دست» در برابر ربات: اولین کسی که ۳ امتیاز بگیرد برنده است و دست مساوی امتیاز ندارد. هر مسابقه ۱ بلیت هزینه دارد و برنده ۵۰ گیتا کوین میگیرد. همهی قابلیتهای پلتفرم در آن هست:
قابلیت
کجای بازی
صفحهی ویکی
کد
ورود با بازینس (انتخاب اسپلش)، جابهجایی حساب، خروج
unzip baziness-example-rps.zip && cd baziness-example-rps
cp .env.example .env# fill in BAZINESS_CLIENT_ID / BAZINESS_CLIENT_SECRET
npm start # → http://127.0.0.1:4380/
پیشنیاز: Node.js 20 یا بالاتر. دادهها در data/db.json نگه داشته میشوند، یک ذخیرهساز JSON کوچک به جای دیتابیس شما.
.env
مقدار توسعه
BAZINESS_CLIENT_ID / BAZINESS_CLIENT_SECRET
کلاینت توسعهی نمونه (از تیم پلتفرم بگیرید، یا کلاینت بازی خودتان)
BAZINESS_PLATFORM_PACKAGE_NAME
com.karizmastudio.example
BAZINESS_PAY_BASE_URL
https://pay-dev.karizmastudios.org
BAZINESS_PAY_PACKAGE_NAME
com.karizmastudio.example
PACK_SKU / PACK_PRICE
example_pack / 4900
پیشنهاد
کلید ندارید؟ باز هم اجرا میشود. با BAZINESS_PLATFORM_ENABLED=false رابط بازینس پنهان میشود و جایزهها محلی نگه داشته میشوند. با DEV_FAKE_BAZINESS_PAY=true و BAZINESS_PAY_BASE_URL=http://localhost:4380/dev/baziness، خریدها از یک درگاه آزمایشی داخلی با دکمهی «پرداخت موفق» رد میشوند.
ورود: روی این نسخهی توسعه، «ورود با حساب بازینس» هر شماره موبایلی را با کد 111111 میپذیرد. پیامکی فرستاده نمیشود.
دوباره دیدن آموزش: از «بازنشانی این دستگاه» پایین لابی استفاده کنید. حافظهی محلی پاک میشود و بار بعد بازیکن کاملاً جدید است.
بازیکن دوم در همان مرورگر:?device=someone-else را به آدرس اضافه کنید.
دریافت جایزه بهعنوان مهمان: بدون ورود یک مسابقه را ببرید. درخواست دریافت PLATFORM_NOT_LINKED برمیگرداند و شیت ورود باز میشود. بعد از ورود، دریافت خودش دوباره تلاش میکند.
خریدی که بعداً تمام میشود: خرید را شروع کنید، پرداخت کنید، بازی را ببندید و دوباره باز کنید. لابی پرداختهای در انتظار را تطبیق میدهد.
لینک کمپین:…/example/?utm_source=wiki&utm_campaign=test را باز کنید. آدرس بعد از بارگذاری تمیز است و برچسبها هنگام ساخت حساب روی آن ذخیره میشوند.
کلاینت فقط از آدرسهای نسبی (api/…، assets/…) استفاده میکند، پس همین فایلها هم در / و هم زیر یک پیشوند کار میکنند. روی سرور توسعهی ویکی، nginx پیشوند /example/ را برمیدارد و درخواست را به پروسهی Node میفرستد: