299 lines
13 KiB
Markdown
299 lines
13 KiB
Markdown
# Integrasi EnakGame: Game Client (Phaser)
|
||||
|
|
|
|||
|
|
**Untuk:** tim game EnakGame (client Phaser) · **Base URL:** `/api/v1` · **Per:** 8 Okt 2026
|
|||
|
|
|
|||
|
|
Kamu mengerjakan **game EnakGame**: game web (Phaser) yang dibuka aplikasi customer di
|
|||
|
|
dalam webview dari `game_url` sebuah game. Game inilah yang menjalankan satu kali main
|
|||
|
|
dari awal sampai akhir: memulai session (EnakCoin dipotong), menjalankan permainan,
|
|||
|
|
mengirim hasil, dan menampilkan hadiah. Jangan mengarang endpoint, field, atau aturan
|
|||
|
|
yang tidak tertulis di sini; kalau ada yang kurang jelas, tanyakan ke tim backend.
|
|||
|
|
|
|||
|
|
Pembagian tugas dengan aplikasi customer:
|
|||
|
|
|
|||
|
|
| Aplikasi customer ([`integration-mobile-customer.md`](./integration-mobile-customer.md)) | Game EnakGame (dokumen ini) |
|
|||
|
|
|---|---|
|
|||
|
|
| Login customer, menyimpan token | Menerima token dari aplikasi lewat bridge (§2) |
|
|||
|
|
| Daftar game, membuka `game_url` di webview | Start session, main, complete, tampilkan hadiah |
|
|||
|
|
| Saldo, riwayat, voucher, PIN | Memberi tahu aplikasi saat saldo berubah atau game ditutup |
|
|||
|
|
|
|||
|
|
Alasan di balik aturannya ada di [`rfc-enakgame.md`](./rfc-enakgame.md) dan
|
|||
|
|
[`enakgame-prd.md`](./enakgame-prd.md).
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 1. Aturan yang tidak boleh dilanggar
|
|||
|
|
|
|||
|
|
1. **Server yang menentukan hadiah.** Game hanya mengirim **hasil main**: `score`,
|
|||
|
|
`outcome`, dan `data`. Jangan pernah mengirim jumlah hadiah. Kalaupun terkirim,
|
|||
|
|
backend mengabaikannya. Untuk spin, server yang mengundi segmennya.
|
|||
|
|
2. **Tampilkan hadiah dari response, bukan dari hitungan sendiri.** Angka di layar akhir
|
|||
|
|
selalu `reward_total` dari backend.
|
|||
|
|
3. **Satu tap "Main" = satu `Idempotency-Key`.** Retry memakai key yang sama.
|
|||
|
|
4. **Token customer adalah rahasia.** Hanya diterima lewat bridge, disimpan di memori,
|
|||
|
|
tidak pernah ditaruh di URL, `localStorage`, cookie, log, atau analytics.
|
|||
|
|
5. **Semua jumlah bilangan bulat.** Tidak ada pecahan EnakCoin.
|
|||
|
|
6. **Main game tidak butuh PIN.**
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 2. Bridge dengan aplikasi customer
|
|||
|
|
|
|||
|
|
> **Usulan.** Bentuk bridge di bawah belum diimplementasikan di sisi mana pun. Sepakati
|
|||
|
|
> dengan tim aplikasi customer sebelum mulai; aplikasi memakai kontrak yang sama
|
|||
|
|
> ([`integration-mobile-customer.md`](./integration-mobile-customer.md) §8.3).
|
|||
|
|
|
|||
|
|
Semua pesan berupa JSON string dengan field `type`.
|
|||
|
|
|
|||
|
|
- **Game → aplikasi:** `window.EnakGameHost.postMessage(JSON.stringify(pesan))`
|
|||
|
|
(JavaScript channel webview bernama `EnakGameHost`).
|
|||
|
|
- **Aplikasi → game:** aplikasi memanggil `window.enakGame.receive(jsonString)`. Game
|
|||
|
|
wajib mendefinisikan fungsi ini sebelum mengirim `ready`.
|
|||
|
|
|
|||
|
|
| Arah | `type` | Isi | Kapan |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| game → app | `ready` | – | Halaman game selesai dimuat |
|
|||
|
|
| app → game | `init` | `api_base_url`, `token`, `game_id` | Jawaban atas `ready` |
|
|||
|
|
| game → app | `token_expired` | – | Backend menolak token (§3) |
|
|||
|
|
| app → game | `token` | `token` | Token baru setelah `token_expired` |
|
|||
|
|
| game → app | `balance_changed` | `coin_balance` | Setelah start dan complete berhasil |
|
|||
|
|
| game → app | `close` | – | Customer keluar dari game |
|
|||
|
|
|
|||
|
|
Contoh `init`:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{ "type": "init", "api_base_url": "https://api.example.com/api/v1", "token": "eyJ…", "game_id": "8a1f…" }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Jangan memanggil API apa pun sebelum `init` diterima. Untuk development di browser
|
|||
|
|
tanpa aplikasi, sediakan mode dev yang mengisi `init` dari config lokal; mode itu tidak
|
|||
|
|
boleh ikut di build produksi.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 3. Koneksi ke API
|
|||
|
|
|
|||
|
|
- Header: `Authorization: Bearer <token>` dari `init`.
|
|||
|
|
- Sukses: `{ "success": true, "data": { … }, "errors": null }`.
|
|||
|
|
- Gagal: `{ "success": false, "data": null, "errors": [{ "code", "entity", "cause" }] }`.
|
|||
|
|
`cause` berbahasa Inggris; jangan tampilkan mentah ke customer.
|
|||
|
|
|
|||
|
|
| `errors[0].code` | HTTP | Arti | Yang dilakukan game |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| `303`, `310` | 400 | Request salah format | Bug di game; pesan umum |
|
|||
|
|
| `304` | 400 | Ditolak aturan bisnis | Lihat tabel per endpoint |
|
|||
|
|
| `404` | 404 | Game/session tidak ada atau bukan milik customer | Pesan "tidak ditemukan", kembali ke aplikasi |
|
|||
|
|
| `900` | 500 | Error server | Retry (§6) |
|
|||
|
|
|
|||
|
|
**Token tidak berlaku** (kedaluwarsa, salah) dijawab HTTP 400 dengan code `304`, sama
|
|||
|
|
seperti penolakan bisnis. Bedakan lewat `entity`: `auth_handler` untuk token,
|
|||
|
|
`enakgame_service` untuk aturan EnakGame. Pada `auth_handler`, kirim `token_expired`,
|
|||
|
|
tunggu `token`, lalu ulangi request yang sama.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 4. Alur satu kali main
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
init ─► GET /customer/enakgame/games ─► tampilkan biaya (dan roda, untuk spin)
|
|||
|
|
─► tap Main ─► POST /customer/enakgame/sessions (EnakCoin dipotong)
|
|||
|
|
─► permainan berjalan (batas waktu: expires_at)
|
|||
|
|
─► POST /customer/enakgame/sessions/:id/complete (server menghitung hadiah)
|
|||
|
|
─► tampilkan hadiah ─► main lagi atau close
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.1 Data game — `GET /customer/enakgame/games`
|
|||
|
|
|
|||
|
|
Mengembalikan semua game aktif organisasi customer. Ambil yang `id`-nya sama dengan
|
|||
|
|
`game_id` dari `init`.
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
[
|
|||
|
|
{
|
|||
|
|
"id": "8a1f…",
|
|||
|
|
"slug": "spin",
|
|||
|
|
"name": "Spin Harian",
|
|||
|
|
"description": null,
|
|||
|
|
"thumbnail_url": "https://…/spin.png",
|
|||
|
|
"game_url": "https://…/spin/index.html",
|
|||
|
|
"version": "1.2.0",
|
|||
|
|
"entry_cost": 5,
|
|||
|
|
"session_ttl_seconds": 600,
|
|||
|
|
"events": [
|
|||
|
|
{ "id": "…", "name": "Ramadan 2x", "banner_url": "https://…", "multiplier": 2, "bonus": null, "end_at": "2026-10-31T16:59:59Z" }
|
|||
|
|
],
|
|||
|
|
"prizes": [
|
|||
|
|
{ "entry": 1, "label": "Zonk", "amount": 0 },
|
|||
|
|
{ "entry": 2, "label": "3 Coin", "amount": 3 },
|
|||
|
|
{ "entry": 3, "label": "10 Coin", "amount": 10 },
|
|||
|
|
{ "entry": 4, "label": "Jackpot", "amount": 50 }
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- `entry_cost`: EnakCoin per main. Tampilkan di tombol Main ("Main · 5 EnakCoin").
|
|||
|
|
- `events`: event yang sedang berlaku, prioritas tertinggi dulu. Tampilkan sebagai
|
|||
|
|
label, mis. "2x hadiah sampai 31 Okt". `multiplier` 2 berarti hadiah dasar ditambah
|
|||
|
|
sekali lagi; `bonus` menambah sejumlah EnakCoin.
|
|||
|
|
- `prizes`: hanya ada untuk game ber-reward `PROBABILITY` (spin). Urutan = urutan segmen
|
|||
|
|
roda. `label` bisa `null`. Bobot peluang tidak pernah dikirim.
|
|||
|
|
- Game tidak ada di daftar → game sudah dinonaktifkan; tampilkan pesan dan `close`.
|
|||
|
|
|
|||
|
|
### 4.2 Mulai — `POST /customer/enakgame/sessions`
|
|||
|
|
|
|||
|
|
Header `Idempotency-Key` wajib (maks. 50 karakter, mis. UUID v4). Buat key baru saat
|
|||
|
|
customer menekan Main; pakai key yang sama bila request diulang karena jaringan.
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{ "game_id": "8a1f…" }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"session_id": "c0d3…",
|
|||
|
|
"game_id": "8a1f…",
|
|||
|
|
"entry_cost": 5,
|
|||
|
|
"expires_at": "2026-10-08T05:10:00Z",
|
|||
|
|
"coin_balance": 15,
|
|||
|
|
"replayed": false
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- EnakCoin sudah terpotong. Kirim `balance_changed` dengan `coin_balance`.
|
|||
|
|
- `replayed: true`: request ini mengulang start yang sudah berhasil; pakai session yang
|
|||
|
|
sama, EnakCoin tidak terpotong dua kali.
|
|||
|
|
- `expires_at`: batas waktu mengirim hasil (default 10 menit sejak start, diatur per
|
|||
|
|
game). Tampilkan timer bila permainan bisa lama.
|
|||
|
|
|
|||
|
|
| Penolakan `304` (`cause`) | Tampilan |
|
|||
|
|
|---|---|
|
|||
|
|
| `not enough EnakCoin` | "EnakCoin kamu kurang." Tombol kembali ke aplikasi |
|
|||
|
|
| `the game is not available` | "Game sedang tidak tersedia." |
|
|||
|
|
| `the game has no active reward configuration` | "Game sedang tidak tersedia." |
|
|||
|
|
| `no EnakGame budget is set for this period` | "Game sedang tidak tersedia." |
|
|||
|
|
| `the customer is not active` | "Akun tidak aktif." |
|
|||
|
|
| `this Idempotency-Key was already used to start another game` | Bug di game: key dipakai ulang untuk game lain |
|
|||
|
|
| `the Idempotency-Key header is required` / `… at most 50 characters` | Bug di game |
|
|||
|
|
|
|||
|
|
### 4.3 Kirim hasil — `POST /customer/enakgame/sessions/:id/complete`
|
|||
|
|
|
|||
|
|
Kirim sekali saat permainan selesai, sebelum `expires_at`. Body berisi hasil saja:
|
|||
|
|
|
|||
|
|
| Field | Tipe | Untuk |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `score` | integer ≥ 0, opsional | Game berbasis skor |
|
|||
|
|
| `outcome` | string, opsional | Game berbasis hasil, mis. `"WIN"`, `"PERFECT"` |
|
|||
|
|
| `data` | objek JSON, opsional, maks. 16 KB | Data tambahan untuk audit (durasi per level, dsb.) |
|
|||
|
|
|
|||
|
|
Spin cukup mengirim `{}`. Game skor: `{ "score": 800 }`. Game hasil:
|
|||
|
|
`{ "outcome": "WIN" }`. Nilai `outcome` yang diterima ditentukan admin per game;
|
|||
|
|
sepakati daftarnya dengan tim backoffice.
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"session_id": "c0d3…",
|
|||
|
|
"status": "COMPLETED",
|
|||
|
|
"reward_total": 10,
|
|||
|
|
"reward": { "base": 5, "event": 5 },
|
|||
|
|
"coin_balance": 25,
|
|||
|
|
"limited_by": ["USER_DAILY"],
|
|||
|
|
"prize": { "entry": 2, "label": "3 Coin", "amount": 3 }
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
| Field | Arti | Tampilan |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `reward_total` | EnakCoin yang **benar-benar masuk** | Angka utama di layar hadiah |
|
|||
|
|
| `reward.base` / `reward.event` | Hadiah dasar dan tambahan event | "5 + 5 bonus event" |
|
|||
|
|
| `coin_balance` | Saldo EnakCoin setelah hadiah | Kirim `balance_changed` |
|
|||
|
|
| `limited_by` | Batas harian yang memotong hadiah: `USER_DAILY`, `GAME_DAILY`, `GLOBAL_DAILY` | "Hadiah hari ini sudah mencapai batas" |
|
|||
|
|
| `prize` | Untuk spin: segmen hasil undian. `amount` = hadiah dasar segmen, sebelum event dan batas | Hentikan roda di `prize.entry` |
|
|||
|
|
| `status` | `COMPLETED`, atau `REFUNDED` bila game dinonaktifkan selama dimainkan | Lihat di bawah |
|
|||
|
|
|
|||
|
|
- **`status: "REFUNDED"`** (`refund_reason: "GAME_DEACTIVATED"`): entry cost
|
|||
|
|
dikembalikan dan tidak ada hadiah. Tampilkan "Game sedang dihentikan, EnakCoin kamu
|
|||
|
|
dikembalikan."
|
|||
|
|
- **`reward_total` 0** bisa terjadi: hadiahnya memang 0 (mis. segmen Zonk), batas harian
|
|||
|
|
sudah habis, atau hasilnya tidak lolos validasi server (skor di atas batas, terlalu
|
|||
|
|
cepat selesai, `outcome` tidak dikenal). Server tidak memberi tahu alasan validasi;
|
|||
|
|
tampilkan hasil apa adanya.
|
|||
|
|
- **Mengirim ulang aman.** Complete untuk session yang sudah selesai mengembalikan
|
|||
|
|
jawaban yang sama, tanpa hadiah dua kali. Tidak perlu `Idempotency-Key`.
|
|||
|
|
|
|||
|
|
| Penolakan | Arti | Tampilan |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `304` `the session has expired` | Lewat `expires_at` | "Waktu bermain habis." (lihat §5) |
|
|||
|
|
| `304` `data must be …` | `data` bukan JSON atau lebih dari 16 KB | Bug di game |
|
|||
|
|
| `310` | `score` bukan bilangan bulat atau `outcome` bukan string | Bug di game |
|
|||
|
|
| `404` | Session tidak ada / milik customer lain | Pesan umum |
|
|||
|
|
|
|||
|
|
### 4.4 Cek status — `GET /customer/enakgame/sessions/:id`
|
|||
|
|
|
|||
|
|
Untuk memulihkan keadaan, mis. game dimuat ulang saat session masih berjalan:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"id": "c0d3…", "game_id": "8a1f…", "status": "STARTED", "entry_cost": 5, "reward_total": 0,
|
|||
|
|
"started_at": "…", "expires_at": "…", "ended_at": null, "refund_reason": null
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`status`: `STARTED`, `COMPLETED`, `REFUNDED`, atau `EXPIRED`. Riwayat main customer ada
|
|||
|
|
di `GET /customer/enakgame/sessions?page=1&limit=20` (dipakai aplikasi, bukan game).
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 5. Batas waktu dan refund
|
|||
|
|
|
|||
|
|
| Keadaan | Yang terjadi pada EnakCoin |
|
|||
|
|
|---|---|
|
|||
|
|
| Hasil dikirim sebelum `expires_at` | Entry cost terpakai, hadiah masuk |
|
|||
|
|
| Customer menutup game / game crash, hasil tidak pernah dikirim | Session menjadi `EXPIRED` setelah `expires_at`. **Entry cost tidak dikembalikan** |
|
|||
|
|
| Complete gagal karena error server (`5xx`) dan tidak berhasil sampai `expires_at` | Session direfund otomatis (`refund_reason: "SYSTEM_ERROR"`) dalam ±1 menit setelah `expires_at` |
|
|||
|
|
| Game dinonaktifkan admin saat dimainkan | Session direfund (`GAME_DEACTIVATED`) |
|
|||
|
|
|
|||
|
|
Karena itu kirim hasil **segera** setelah permainan selesai, sebelum animasi panjang.
|
|||
|
|
Saat customer menekan keluar di tengah permainan, tampilkan konfirmasi "EnakCoin yang
|
|||
|
|
sudah dipakai tidak kembali".
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 6. Retry dan jaringan
|
|||
|
|
|
|||
|
|
| Request | Gagal karena jaringan / `5xx` | Aturan |
|
|||
|
|
|---|---|---|
|
|||
|
|
| Start | Ulangi dengan **`Idempotency-Key` yang sama** | Key baru = potong EnakCoin lagi |
|
|||
|
|
| Complete | Ulangi dengan body yang sama sampai berhasil atau `expires_at` lewat | Aman diulang |
|
|||
|
|
| Token ditolak (`entity` `auth_handler`) | `token_expired` → tunggu `token` → ulangi | Jangan minta customer login dari dalam game |
|
|||
|
|
|
|||
|
|
Gunakan backoff (mis. 1 s, 2 s, 4 s) dan tampilkan indikator "Menyimpan hasil…" selama
|
|||
|
|
complete diulang.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 7. Spin
|
|||
|
|
|
|||
|
|
1. Gambar roda dari `prizes` (§4.1): satu segmen per entri, urut, dengan `label`
|
|||
|
|
(atau `amount` bila `label` `null`).
|
|||
|
|
2. Tap Putar → start session (§4.2).
|
|||
|
|
3. Mulai animasi berputar, lalu langsung kirim complete dengan `{}`.
|
|||
|
|
4. Dari response, hentikan roda di segmen `prize.entry`, lalu tampilkan `reward_total`.
|
|||
|
|
|
|||
|
|
Jangan menentukan segmen sendiri lalu "mencocokkan" dengan server. Bila `prize` tidak
|
|||
|
|
ada di response, hasil tidak bisa ditampilkan sebagai roda; tampilkan `reward_total`
|
|||
|
|
saja.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 8. Checklist
|
|||
|
|
|
|||
|
|
- [ ] Bridge sesuai kontrak §2 yang sudah disepakati dengan tim aplikasi.
|
|||
|
|
- [ ] Token hanya di memori; tidak ada di URL, storage, log, atau analytics.
|
|||
|
|
- [ ] Biaya main dan label event tampil sebelum main.
|
|||
|
|
- [ ] Satu `Idempotency-Key` per tap Main, dipakai ulang saat retry.
|
|||
|
|
- [ ] Complete hanya mengirim `score` / `outcome` / `data`, tidak pernah hadiah.
|
|||
|
|
- [ ] Hadiah di layar dari `reward_total`; `limited_by` dan `REFUNDED` ditangani.
|
|||
|
|
- [ ] Spin berhenti di `prize.entry`.
|
|||
|
|
- [ ] Complete diulang dengan aman saat gagal; timeout `expires_at` ditangani.
|
|||
|
|
- [ ] `balance_changed` dikirim setelah start dan complete; `close` saat keluar.
|