2026-10-08 11:10:05 +07:00
# Integrasi EnakGame: Game Client (Phaser)
2026-10-09 22:59:58 +07:00
**Untuk:** tim game EnakGame (client Phaser) · **Base URL:** `/api/v1` · **Per:** 9 Okt 2026
2026-10-08 11:10:05 +07:00
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) |
|---|---|
2026-10-09 22:59:58 +07:00
| Login customer, menyimpan token | Menerima token dari aplikasi lewat bridge (§4); bila tidak ada, meminta customer login (§5) |
2026-10-08 11:10:05 +07:00
| 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.
2026-10-09 22:59:58 +07:00
4. **Token customer adalah rahasia.** Hanya didapat dari bridge atau dari login di
game, disimpan di memori, tidak pernah ditaruh di URL, `localStorage` ,
`sessionStorage` , cookie, log, atau analytics. Begitu juga password customer.
(`session_id` boleh disimpan di `sessionStorage` , §6.4.)
5. **Tanpa token, jangan panggil API customer.** Tampilkan layar login akun customer
(§5.2).
6. **Semua jumlah bilangan bulat.** Tidak ada pecahan EnakCoin.
7. **Main game tidak butuh PIN.**
2026-10-08 11:10:05 +07:00
---
2026-10-09 22:59:58 +07:00
## 2. Daftar game
Game bukan daftar tetap di kode. Setiap game adalah data yang dibuat admin di
backoffice per organisasi (nama, `slug` , `game_url` , biaya main, aturan hadiah) dan
dibaca game lewat `GET /customer/enakgame/games` (§6.1). Satu `game_url` = satu game.
### 2.1 Game yang ada sekarang
| Game | `slug` | Jenis hadiah (`reward_type` ) | Body complete | Status |
|---|---|---|---|---|
| Spin Harian | `spin` | `PROBABILITY` : server mengundi segmen roda | `{}` | Backend siap. Panduan: §9 |
Runner, Memory, dan Puzzle hanya disebut sebagai contoh di PRD; belum ada spesifikasi,
`slug` , atau aturan hadiahnya. Game baru harus didaftarkan dulu (§2.3) sebelum bisa
dimainkan.
### 2.2 Jenis hadiah menentukan apa yang dikirim game
API customer tidak memberi tahu `reward_type` . Jenisnya disepakati saat game
didaftarkan (§2.3), dan game dibangun untuk jenis itu.
| `reward_type` | Hadiah dihitung dari | Body complete | Bila field-nya tidak dikirim |
|---|---|---|---|
| `FIXED` | Jumlah tetap per main | `{}` | – |
| `SCORE_BASED` | Rentang skor yang diatur admin | `{ "score": 800 }` | Hadiah 0 |
| `OUTCOME_BASED` | Hasil main, mis. `PERFECT` , `GOOD` , `FAIL` | `{ "outcome": "PERFECT" }` | Hadiah 0. `outcome` yang tidak terdaftar juga 0 |
| `PROBABILITY` | Undian server; peluang tidak pernah dikirim ke game | `{}` | – |
Admin bisa mengubah besar hadiah kapan saja tanpa build game baru. Supaya game tetap
benar bila admin juga mengganti jenisnya, **kirim semua hasil yang dimiliki game** :
game berbasis skor selalu mengirim `score` , game berbasis hasil selalu mengirim
`outcome` . Field yang tidak dipakai jenis hadiah aktif diabaikan.
### 2.3 Mendaftarkan game baru
Sepakati dengan tim backoffice, lalu admin membuatnya
([`integration-backoffice.md` ](./integration-backoffice.md ) §8):
| Yang disepakati | Contoh | Catatan |
|---|---|---|
| `slug` | `runner` | Huruf kecil, angka, `-` ; unik per organisasi. Game memakainya untuk memeriksa dirinya (§6.1) |
| `game_url` | `https://…/runner/index.html` | URL build game; dibuka aplikasi di webview |
| `version` | `1.0.0` | Versi build |
| `reward_type` | `SCORE_BASED` | §2.2 |
| Daftar `outcome` | `WIN` , `LOSE` | Hanya game `OUTCOME_BASED` ; harus sama persis (huruf besar/kecil) |
| `result_rules` | `max_score` , `min_duration_seconds` , `max_score_per_second` | Hasil di luar batas ini mendapat hadiah 0 tanpa pemberitahuan (§6.3). Isi dengan skor dan durasi wajar game-mu |
| `session_ttl_seconds` | `600` | Batas waktu satu main. Harus lebih lama dari durasi main terpanjang, ditambah jeda jaringan |
| `entry_cost` | `5` | Ditentukan bisnis, minimal 1 |
---
## 3. Daftar API
Semua endpoint diawali `api_base_url` (dari `init` , §4, atau dari config build pada
mode mandiri, §5.4), mis. `https://api.example.com/api/v1` . Selain login, semua wajib
memakai header `Authorization: Bearer <token>` . Request ber-body memakai
`Content-Type: application/json` .
| # | Endpoint | Header tambahan | Body | Dipakai saat | Detail |
|---|---|---|---|---|---|
| 0 | `POST /customer-auth/login` | Tanpa `Authorization` | `{ "phone_number", "password" }` | Tidak ada token, atau token ditolak | §5.3 |
| 1 | `GET /customer/enakgame/games` | – | – | Setelah ada token: biaya main, event, segmen roda | §6.1 |
| 2 | `POST /customer/enakgame/sessions` | `Idempotency-Key` (wajib) | `{ "game_id" }` | Customer menekan Main; EnakCoin dipotong | §6.2 |
| 3 | `POST /customer/enakgame/sessions/:id/complete` | – | `{ "score"?, "outcome"?, "data"? }` | Permainan selesai | §6.3 |
| 4 | `GET /customer/enakgame/sessions/:id` | – | – | Pemulihan setelah reload, bila `session_id` tersimpan | §6.4 |
| 5 | `GET /customer/enakgame/sessions?game_id=&status=&page=&limit=` | – | – | Pemulihan setelah reload, bila `session_id` tidak tersimpan | §6.4 |
Game tidak memanggil endpoint lain. Registrasi, saldo, riwayat, voucher, dan PIN
adalah tugas aplikasi customer.
---
## 4. Bridge dengan aplikasi customer
2026-10-08 11:10:05 +07:00
> **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 |
2026-10-09 22:59:58 +07:00
| app → game | `init` | `api_base_url` , `token` , `game_id` | Jawaban atas `ready` . `token` boleh kosong bila customer belum login |
2026-10-08 11:10:05 +07:00
| 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…" }
```
2026-10-09 22:59:58 +07:00
Di dalam aplikasi, jangan memanggil API apa pun sebelum `init` diterima. Token yang
kosong atau ditolak tidak dikembalikan ke aplikasi: game sendiri yang meminta customer
login (§5.2). Tanpa aplikasi (browser biasa), game berjalan dalam mode mandiri (§5.4).
2026-10-08 11:10:05 +07:00
---
2026-10-09 22:59:58 +07:00
## 5. Koneksi ke API dan token
### 5.1 Bentuk response dan error
2026-10-08 11:10:05 +07:00
- Sukses: `{ "success": true, "data": { … }, "errors": null }` .
- Gagal: `{ "success": false, "data": null, "errors": [{ "code", "entity", "cause" }] }` .
`cause` berbahasa Inggris; jangan tampilkan mentah ke customer.
2026-10-09 22:59:58 +07:00
- Contoh response di dokumen ini adalah isi `data` , kecuali yang menampilkan amplop
lengkap (§6.4).
2026-10-08 11:10:05 +07:00
| `errors[0].code` | HTTP | Arti | Yang dilakukan game |
|---|---|---|---|
2026-10-09 22:59:58 +07:00
| `304` dengan `entity` `auth_handler` | 400 | **Token tidak ada atau tidak berlaku** | Layar login (§5.2) |
2026-10-08 11:10:05 +07:00
| `304` | 400 | Ditolak aturan bisnis | Lihat tabel per endpoint |
2026-10-09 22:59:58 +07:00
| `303` , `310` | 400 | Request salah format | Bug di game; pesan umum |
2026-10-08 11:10:05 +07:00
| `404` | 404 | Game/session tidak ada atau bukan milik customer | Pesan "tidak ditemukan", kembali ke aplikasi |
2026-10-09 22:59:58 +07:00
| `900` | 500 | Error server | Retry (§8) |
2026-10-08 11:10:05 +07:00
2026-10-09 22:59:58 +07:00
Backend **tidak** memakai HTTP 401. Token yang ditolak dijawab HTTP 400 dengan code
`304` , sama seperti penolakan bisnis; bedakan lewat `entity` : `auth_handler` untuk token,
`enakgame_service` untuk aturan EnakGame.
### 5.2 Token tidak ada atau ditolak: customer login di game
Token didapat dari `init` (game dibuka dari aplikasi) atau dari login di game. Setiap
kali tidak ada token yang berlaku, game menampilkan **layar login akun customer**
(§5.3).
| Keadaan | Cara mengenali | Yang dilakukan game |
|---|---|---|
| `init` datang dengan token | `token` terisi | Pakai token itu, tanpa layar login |
| `init` datang tanpa token | `token` kosong, `null` , atau tidak ada | Layar login |
| Dibuka tanpa aplikasi (browser biasa) | `window.EnakGameHost` tidak ada | Mode mandiri (§5.4), dimulai dari layar login |
| `init` tidak datang | Tidak ada `init` 5 detik setelah `ready` | Kirim `ready` sekali lagi. Masih tidak datang dalam 5 detik: mode mandiri (§5.4) |
| Token ditolak backend | HTTP 400, `code` `304` , `entity` `auth_handler` | Layar login dengan pesan "Sesi kamu berakhir, silakan login lagi". Setelah login berhasil, ulangi request yang sama **satu kali** (body dan `Idempotency-Key` sama) |
| Token hasil login langsung ditolak lagi | Penolakan `auth_handler` kedua untuk request yang sama | Berhenti: "Login bermasalah, coba buka ulang game" dengan tombol Keluar. Jangan menampilkan login berulang-ulang |
| Customer login dengan akun lain | `GET /sessions/:id` untuk session tersimpan menjawab `404` | Hapus `session_id` dari `sessionStorage` , tampilkan layar awal (§6.4) |
Token ditolak sebelum apa pun diproses: start yang ditolak tidak memotong EnakCoin, dan
complete yang ditolak tidak menyelesaikan session. Jadi aman mengulang request yang
sama setelah login. Timer `expires_at` tetap berjalan selama customer login; complete
setelah `expires_at` ditolak dan entry cost **tidak** dikembalikan (§7).
Token hasil login di game tidak dikirim ke aplikasi; aplikasi mengurus tokennya
sendiri.
`cause` dari `auth_handler` hanya untuk debugging, jangan dicocokkan di kode:
| `cause` | Penyebab |
|---|---|
| `Authorization header is required` | Header tidak dikirim. Bug di game: memanggil API sebelum ada token |
| `Invalid authorization header format` | Header bukan `Bearer <token>` |
| `Invalid token: …` | Token kedaluwarsa, rusak, atau dari environment lain (staging vs. produksi) |
| `Invalid token type` | Yang dipakai refresh token, bukan access token |
| `Token is not valid` , `Customer ID not found in token` , `Phone number not found in token` | Token bukan token customer yang sah |
### 5.3 Layar login — `POST /customer-auth/login`
Form berisi nomor HP dan password akun customer, sama dengan akun di aplikasi. Endpoint
ini tidak memakai header `Authorization` .
```json
{ "phone_number" : "6281234561234" , "password" : "rahasia123" }
```
Response lengkap, termasuk amplopnya. Token ada di ** `data.data.access_token` **:
```json
{
"success" : true ,
"data" : {
"status" : "SUCCESS" ,
"message" : "Login successful." ,
"data" : {
"access_token" : "eyJ…" ,
"refresh_token" : "eyJ…" ,
"user" : { "id" : "…" , "name" : "Budi" , "phone_number" : "6281234561234" , "birth_date" : "2000-01-31" }
}
},
"errors" : null
}
```
- Pakai `access_token` saja. `refresh_token` ditolak endpoint customer dan belum ada
endpoint refresh; abaikan dan jangan disimpan.
- Token hanya di memori (§1). Halaman dimuat ulang berarti login lagi, kecuali di dalam
aplikasi yang mengirim token lewat `init` .
- Password hanya dipegang selama request login: jangan disimpan, di-log, atau dikirim
ke analytics.
- `user.name` boleh ditampilkan, mis. "Main sebagai Budi", dengan tombol "Ganti akun"
yang menghapus token dari memori lalu menampilkan login lagi.
- Nomor HP boleh ditulis `0812…` , `+62 812…` , `62812…` , atau `812…` (spasi dan `-`
boleh). Backend mengubahnya menjadi format baku ** `62812…` **, dan format itu juga yang
dikembalikan di `user.phone_number` . Hanya nomor HP Indonesia (`628…` ) yang diterima;
selain itu ditolak `invalid phone number format` .
- Registrasi tidak ada di game. Tampilkan "Belum punya akun atau pendaftaran belum
selesai? Lanjutkan di aplikasi."
| `code` | `entity` | HTTP | Arti (`cause` ) | Tampilan |
|---|---|---|---|---|
| `304` | `customer_auth_service` | 400 | Nomor HP tidak terdaftar atau password salah (`invalid phone number or password` ), atau pendaftaran belum selesai (`customer not properly registered` ) | ** "Nomor HP atau password salah."** |
| `304` | `request` | 400 | Format salah: `invalid phone number format` , `phone number is required` , `password is required` | "Periksa nomor HP dan password." Cek juga di game sebelum mengirim |
| `303` | `request` | 400 | Body tidak lengkap | Bug di game |
| `429` | `customer_auth_service` | 429 | Terlalu banyak percobaan login untuk nomor ini; `data.locked_until` (RFC3339 UTC) | "Terlalu banyak percobaan. Coba lagi pukul {jam}." Nonaktifkan tombol Masuk sampai `locked_until` |
| `900` | – | 500 | Error server | "Gagal login, coba lagi." |
Login yang gagal jangan diulang otomatis. Setiap nomor HP hanya boleh mencoba login
5 kali dalam 15 menit, berhasil maupun gagal; login yang berhasil memulai hitungan
dari nol. Percobaan ke-6 ditolak `429` sampai 15 menit itu habis, juga bila
password-nya benar. Batas ini berlaku untuk nomor HP, bukan perangkat, dan sama untuk
aplikasi customer.
### 5.4 Mode mandiri (tanpa aplikasi)
Dipakai saat game dibuka di browser biasa, atau saat aplikasi tidak mengirim `init`
(§5.2). Juga dipakai untuk development dengan config staging.
1. `api_base_url` diambil dari config build per environment (staging, produksi), bukan
dari URL atau input customer.
2. Tampilkan layar login (§5.3).
3. Panggil `GET /customer/enakgame/games` dan ambil game dengan `slug` milik build ini;
`id` -nya menjadi `game_id` . Tidak ada → "Game tidak tersedia untuk akun ini."
4. Lanjutkan seperti biasa: pemulihan session (§6.4), lalu layar awal.
Tanpa aplikasi tidak ada bridge: `balance_changed` dan `close` tidak dikirim, dan tombol
Keluar kembali ke layar awal game.
### 5.5 Contoh helper API
Helper ini menangani token dari `init` , layar login, dan token yang ditolak:
```js
let apiBaseUrl = CONFIG . apiBaseUrl ; // config build per environment; init menimpanya
let gameId = null ; // dari init; mode mandiri: dicari lewat slug (§5.4)
let token = null ; // hanya di memori
let loginWaiters = []; // request yang menunggu customer login
const hasHost = () => typeof window . EnakGameHost !== 'undefined' ;
const toHost = ( msg ) => hasHost () && window . EnakGameHost . postMessage ( JSON . stringify ( msg ));
window . enakGame = {
receive ( raw ) {
const msg = JSON . parse ( raw );
if ( msg . type !== 'init' ) return ;
apiBaseUrl = msg . api_base_url ;
gameId = msg . game_id ;
token = msg . token || null ; // kosong → layar login saat request pertama
onInit (); // pemulihan session (§6.4), lalu layar awal
},
};
// Menampilkan layar login; selesai setelah customer berhasil login.
function waitForLogin ( message ) {
return new Promise (( resolve ) => {
if ( loginWaiters . length === 0 ) showLoginScreen ( message );
loginWaiters . push ( resolve );
});
}
// Dipanggil tombol Masuk. Error dilempar ke layar login dan ditampilkan sesuai §5.3.
async function login ( phoneNumber , password ) {
const res = await fetch ( apiBaseUrl + '/customer-auth/login' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ phone_number : phoneNumber , password }),
});
const json = await res . json (). catch (() => null );
if ( ! json ? . success ) throw json ? . errors ? .[ 0 ] ?? { code : String ( res . status ) };
token = json . data . data . access_token ;
hideLoginScreen ();
loginWaiters . splice ( 0 ). forEach (( resolve ) => resolve ());
}
// Mengembalikan isi `data`, atau melempar errors[0]. Error jaringan ikut dilempar,
// lalu ditangani pemanggil sesuai §8.
async function api ( method , path , { body , idempotencyKey } = {}) {
for ( let attempt = 0 ; ; attempt ++ ) {
if ( ! token ) await waitForLogin ( attempt === 0 ? null : 'Sesi kamu berakhir, silakan login lagi.' );
const headers = { Authorization : `Bearer ${ token } ` };
if ( body !== undefined ) headers [ 'Content-Type' ] = 'application/json' ;
if ( idempotencyKey ) headers [ 'Idempotency-Key' ] = idempotencyKey ;
const res = await fetch ( apiBaseUrl + path , {
method ,
headers ,
body : body === undefined ? undefined : JSON . stringify ( body ),
});
const json = await res . json (). catch (() => null );
if ( json ? . success ) return json . data ;
const err = json ? . errors ? .[ 0 ] ?? { code : String ( res . status ) };
if ( err . entity === 'auth_handler' && attempt === 0 ) {
token = null ; // login lagi, lalu ulangi request yang sama sekali
continue ;
}
throw err ;
}
}
```
2026-10-08 11:10:05 +07:00
---
2026-10-09 22:59:58 +07:00
## 6. Alur satu kali main
2026-10-08 11:10:05 +07:00
```
2026-10-09 22:59:58 +07:00
init ─► token ada? tidak: login (§5.2) ─► cek session yang masih berjalan (§6.4)
2026-10-08 12:51:39 +07:00
─► GET /customer/enakgame/games ─► tampilkan biaya (dan roda, untuk spin)
2026-10-08 11:10:05 +07:00
─► 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
```
2026-10-09 22:59:58 +07:00
### 6.1 Data game — `GET /customer/enakgame/games`
2026-10-08 11:10:05 +07:00
Mengembalikan semua game aktif organisasi customer. Ambil yang `id` -nya sama dengan
2026-10-09 22:59:58 +07:00
`game_id` dari `init` ; pada mode mandiri, yang `slug` -nya milik build ini (§5.4).
2026-10-08 11:10:05 +07:00
```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` .
2026-10-09 22:59:58 +07:00
- `slug` bukan yang dibangun untuk build ini (mis. build spin menerima game `runner` )
→ `game_url` salah dipasang admin. Tampilkan "Game sedang tidak tersedia", `close` ,
dan laporkan ke tim backoffice.
2026-10-08 11:10:05 +07:00
2026-10-09 22:59:58 +07:00
### 6.2 Mulai — `POST /customer/enakgame/sessions`
2026-10-08 11:10:05 +07:00
Header `Idempotency-Key` wajib (maks. 50 karakter, mis. UUID v4). Buat key baru saat
2026-10-09 22:59:58 +07:00
customer menekan Main; pakai key yang sama bila request diulang karena jaringan atau
karena customer login ulang (§5.2).
2026-10-08 11:10:05 +07:00
```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 |
2026-10-09 22:59:58 +07:00
### 6.3 Kirim hasil — `POST /customer/enakgame/sessions/:id/complete`
2026-10-08 11:10:05 +07:00
2026-10-09 22:59:58 +07:00
Kirim sekali saat permainan selesai, sebelum `expires_at` . Body berisi hasil saja,
sesuai jenis hadiah game (§2.2):
2026-10-08 11:10:05 +07:00
| 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:
2026-10-09 22:59:58 +07:00
`{ "outcome": "WIN" }` . Nilai `outcome` yang diterima ditentukan admin per game
(§2.3).
2026-10-08 11:10:05 +07:00
```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
2026-10-09 22:59:58 +07:00
sudah habis, field yang dibutuhkan jenis hadiahnya tidak dikirim (§2.2), 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.
2026-10-08 11:10:05 +07:00
- **Mengirim ulang aman.** Complete untuk session yang sudah selesai mengembalikan
jawaban yang sama, tanpa hadiah dua kali. Tidak perlu `Idempotency-Key` .
| Penolakan | Arti | Tampilan |
|---|---|---|
2026-10-09 22:59:58 +07:00
| `304` `the session has expired` | Lewat `expires_at` | "Waktu bermain habis." (lihat §7) |
2026-10-08 11:10:05 +07:00
| `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 |
2026-10-09 22:59:58 +07:00
### 6.4 Pemulihan setelah reload
2026-10-08 11:10:05 +07:00
2026-10-08 12:51:39 +07:00
Webview bisa memuat ulang halaman game (aplikasi ke background, memori habis, crash)
saat customer sedang main. EnakCoin sudah terpotong, jadi game wajib menemukan lagi
session-nya. Dua endpoint dipakai:
**Satu session** — `GET /customer/enakgame/sessions/:id`
2026-10-08 11:10:05 +07:00
```json
{
"id" : "c0d3…" , "game_id" : "8a1f…" , "status" : "STARTED" , "entry_cost" : 5 , "reward_total" : 0 ,
"started_at" : "…" , "expires_at" : "…" , "ended_at" : null , "refund_reason" : null
}
```
2026-10-08 12:51:39 +07:00
`status` : `STARTED` , `COMPLETED` , `REFUNDED` , atau `EXPIRED` . Response ini tidak memuat
`prize` atau rincian hadiah; untuk itu kirim ulang complete (lihat di bawah).
**Mencari session** — `GET /customer/enakgame/sessions?game_id=8a1f…&status=STARTED&limit=1`
2026-10-09 22:59:58 +07:00
Response lengkap, termasuk amplopnya. Daftar session ada di ** `data.data` **, terbaru di
atas:
```json
{
"success" : true ,
"data" : {
"data" : [
{
"id" : "c0d3…" , "game_id" : "8a1f…" , "status" : "STARTED" , "entry_cost" : 5 , "reward_total" : 0 ,
"started_at" : "…" , "expires_at" : "…" , "ended_at" : null , "refund_reason" : null
}
],
"pagination" : { "page" : 1 , "limit" : 1 , "total_count" : 1 , "total_pages" : 1 }
},
"errors" : null
}
```
Tidak ada session yang cocok: `data.data` berupa array kosong `[]` (tidak pernah
`null` ). Bentuk lain berarti error, bukan "tidak ada session". Semua query opsional:
`game_id` , `status` (`STARTED` , `COMPLETED` , `REFUNDED` , `EXPIRED` ), `page` , `limit` .
`status` atau `game_id` yang tidak valid ditolak `304` .
Bila pengecekan ini gagal (jaringan, `5xx` ), **jangan** menganggap tidak ada session:
customer bisa terpotong EnakCoin dua kali. Tampilkan "Coba lagi" sampai berhasil.
2026-10-08 12:51:39 +07:00
**Alurnya, setiap kali menerima `init`:**
1. Simpan `session_id` di ** `sessionStorage` ** setiap kali start berhasil, dan hapus
setelah hasilnya ditampilkan. `session_id` bukan rahasia; token tetap hanya di
memori (§1).
2. Bila ada `session_id` tersimpan, panggil `GET /sessions/:id` . Bila tidak ada (mis.
webview dibuka ulang dari awal), panggil
`GET /sessions?game_id=<game_id>&status=STARTED&limit=1` .
3. Tindak lanjuti sesuai status:
| Keadaan | Yang dilakukan game |
|---|---|
2026-10-09 22:59:58 +07:00
| `STARTED` , sekarang sebelum `expires_at` | **Lanjutkan** session itu: jangan start baru (EnakCoin akan terpotong lagi). Spin: langsung kirim complete `{}` dan tampilkan hasilnya. Game lain: progres main hilang, jadi mulai ulang permainan di session yang sama dengan timer sampai `expires_at` , lalu kirim complete. Complete setelah `expires_at` ditolak, jadi **akhiri ronde otomatis dan kirim skor saat itu** beberapa detik sebelum `expires_at` (mis. 5 detik, untuk jeda jaringan dan selisih jam perangkat) |
2026-10-08 12:51:39 +07:00
| `STARTED` , `expires_at` sudah lewat | Anggap selesai. Server mengubahnya menjadi `EXPIRED` (atau merefund bila complete sebelumnya gagal karena error server) dalam ±1 menit. Tampilkan "Waktu bermain habis", lalu customer boleh start baru |
| `COMPLETED` | Hasil sudah dihitung tapi mungkin belum ditampilkan. Kirim ulang `POST /sessions/:id/complete` dengan body apa saja (`{}` ): server mengembalikan jawaban yang sama persis, termasuk `prize` , tanpa hadiah dobel. Tampilkan hasilnya |
| `REFUNDED` | "EnakCoin kamu dikembalikan." |
| `EXPIRED` | "Waktu bermain habis." |
2026-10-09 22:59:58 +07:00
| `404` untuk `session_id` tersimpan | Session milik akun lain (customer berganti akun). Hapus `session_id` , lanjut seperti tidak ada session |
2026-10-08 12:51:39 +07:00
| Tidak ada session | Tampilkan layar awal seperti biasa |
Riwayat main lengkap (tanpa filter) dipakai aplikasi customer, bukan game.
2026-10-08 11:10:05 +07:00
---
2026-10-09 22:59:58 +07:00
## 7. Batas waktu dan refund
2026-10-08 11:10:05 +07:00
| 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** |
2026-10-09 22:59:58 +07:00
| Complete tertahan karena customer harus login ulang sampai `expires_at` lewat | Sama dengan di atas: `EXPIRED` , **entry cost tidak dikembalikan** |
2026-10-08 11:10:05 +07:00
| 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".
---
2026-10-09 22:59:58 +07:00
## 8. Retry dan jaringan
2026-10-08 11:10:05 +07:00
| 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 |
2026-10-09 22:59:58 +07:00
| Token ditolak (`entity` `auth_handler` ) | Bukan error jaringan; layar login (§5.2) | Setelah login, ulangi sekali |
| Login gagal | Tampilkan pesan (§5.3) | Jangan diulang otomatis |
2026-10-08 11:10:05 +07:00
Gunakan backoff (mis. 1 s, 2 s, 4 s) dan tampilkan indikator "Menyimpan hasil…" selama
complete diulang.
---
2026-10-09 22:59:58 +07:00
## 9. Spin
2026-10-08 11:10:05 +07:00
2026-10-09 22:59:58 +07:00
1. Gambar roda dari `prizes` (§6.1): satu segmen per entri, urut, dengan `label`
2026-10-08 11:10:05 +07:00
(atau `amount` bila `label` `null` ).
2026-10-09 22:59:58 +07:00
2. Tap Putar → start session (§6.2).
2026-10-08 11:10:05 +07:00
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.
---
2026-10-09 22:59:58 +07:00
## 10. Checklist
2026-10-08 11:10:05 +07:00
2026-10-09 22:59:58 +07:00
- [ ] Game baru sudah didaftarkan bersama tim backoffice (§2.3); `slug` diperiksa saat `init` (§6.1).
- [ ] Body complete sesuai jenis hadiah game (§2.2).
- [ ] Bridge sesuai kontrak §4 yang sudah disepakati dengan tim aplikasi.
2026-10-08 11:10:05 +07:00
- [ ] Token hanya di memori; tidak ada di URL, storage, log, atau analytics.
2026-10-09 22:59:58 +07:00
- [ ] Tanpa token tidak ada request; token kosong atau ditolak → layar login → ulangi request sekali (§5.2).
- [ ] Login memakai `access_token` ; password tidak disimpan; nomor HP/password salah tampil sebagai pesan yang jelas (§5.3).
- [ ] Mode mandiri (§5.4): dibuka di browser atau `init` tidak datang → login, `game_id` dicari lewat `slug` .
- [ ] Pemulihan setelah reload (§6.4): session `STARTED` dilanjutkan, bukan start baru; `COMPLETED` ditampilkan lewat complete ulang.
2026-10-08 11:10:05 +07:00
- [ ] 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.