> ## Documentation Index
> Fetch the complete documentation index at: https://playwave.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# API 직접 연동 가이드

> PlayWave 플러그인 없이 HTTP API를 직접 호출하여 연동하는 방법

PlayWave Roblox SDK 없이 HTTP API를 직접 호출하여 연동하는 방법을 안내합니다.

## 연동 흐름

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant L as PlayWave 런처
    participant C as Roblox Client
    participant GS as Game Server
    participant API as PlayWave API

    L->>C: 게임 실행 (LaunchData = OTT)
    C->>GS: PlayerAdded
    GS->>GS: LaunchData에서 OTT 추출
    GS->>API: POST /session/verify (OTT + UserId)
    API-->>GS: game_session_id + is_pc_cafe

    loop 매 2분
        GS->>API: PATCH /session/heartbeat
        API-->>GS: OK + play_duration_sec
    end

    C->>GS: PlayerRemoving
    GS->>API: DELETE /session/end
    API-->>GS: SUCCESS + play_duration_sec
```

## 전제조건

* **API Key 발급**: PlayWave 서비스 운영 담당자에게 요청
* **Roblox Studio**: Game Settings → Security → **Allow HTTP Requests** 활성화
* **서버 사이드 전용**: 모든 API 호출은 `ServerScriptService`에서 실행

### API 서버 주소

| 환경       | URL                                   | 상태                                 |
| -------- | ------------------------------------- | ---------------------------------- |
| **Dev**  | `https://wave-api.playwave.dev/v1`    | <Badge color="green">사용 가능</Badge> |
| **QA**   | `https://wave-api-qa.playwave.dev/v1` | <Badge color="yellow">준비 중</Badge> |
| **Live** | `https://wave-api.playwave.io/v1`     | <Badge color="yellow">준비 중</Badge> |

## 인증

모든 Game Server API 요청에는 `X-Api-Key` 헤더가 필요합니다.

```
X-Api-Key: {발급받은_api_key}
Content-Type: application/json
```

<Warning>
  API Key는 **반드시 ServerScriptService의 Script에서만** 사용하세요. `ReplicatedStorage`, `StarterPlayerScripts`, `LocalScript` 등 클라이언트에서 접근 가능한 곳에 절대 배치하지 마세요.
</Warning>

## OTT 검증 — POST /v1/game/session/verify

플레이어가 게임에 접속하면 가장 먼저 호출합니다. OTT를 검증하고 게임 세션을 생성합니다.

### OTT 추출

PlayWave 런처는 게임 실행 시 `LaunchData`에 OTT UUID를 직접 전달합니다.

```lua theme={null}
local function extractOtt(player)
    local success, joinData = pcall(function()
        return player:GetJoinData()
    end)

    if not success or not joinData then
        return nil
    end

    local launchData = joinData.LaunchData
    if not launchData or type(launchData) ~= "string" or #launchData == 0 then
        return nil
    end

    return launchData
end
```

### 요청

```lua theme={null}
local HttpService = game:GetService("HttpService")

local API_URL = "https://wave-api.playwave.dev/v1"
local API_KEY = "your-api-key-here"

local function verifySession(player)
    local ott = extractOtt(player)
    if not ott then
        return { is_valid = false, reason = "NO_OTT" }
    end

    local success, response = pcall(function()
        return HttpService:RequestAsync({
            Url = API_URL .. "/game/session/verify",
            Method = "POST",
            Headers = {
                ["X-Api-Key"] = API_KEY,
                ["Content-Type"] = "application/json",
            },
            Body = HttpService:JSONEncode({
                ott = ott,
                provider_user_id = tostring(player.UserId),
            }),
        })
    end)

    if not success then
        warn("[Playwave] HTTP request failed:", response)
        return { is_valid = false, reason = "NETWORK_ERROR" }
    end

    local decodeOk, body = pcall(function()
        return HttpService:JSONDecode(response.Body)
    end)

    if not decodeOk or not body.success or not body.data then
        return { is_valid = false, reason = "API_ERROR" }
    end

    return body.data
end
```

### 성공 응답

```json theme={null}
{
  "success": true,
  "request_id": "req_...",
  "data": {
    "is_valid": true,
    "game_session_id": "gs_550e8400-e29b-41d4-a716-446655440000",
    "is_pc_cafe": true
  }
}
```

| 필드                | 타입      | 설명                     |
| ----------------- | ------- | ---------------------- |
| `is_valid`        | boolean | 검증 성공 여부               |
| `game_session_id` | string  | 게임 세션 ID (하트비트/종료에 사용) |
| `is_pc_cafe`      | boolean | PC방 여부                 |

### 실패 응답

```json theme={null}
{
  "success": true,
  "request_id": "req_...",
  "data": {
    "is_valid": false,
    "reason": "OTT_EXPIRED"
  }
}
```

<Note>
  비즈니스 로직 실패도 HTTP 200으로 반환됩니다. 반드시 `is_valid` 값으로 분기하세요.
</Note>

### reason 값과 처리

| reason             | 설명                       | 게임 서버 처리             |
| ------------------ | ------------------------ | -------------------- |
| `OTT_EXPIRED`      | OTT 만료 (발급 후 1분 초과)      | 유저에게 런처로 돌아가서 재실행 안내 |
| `OTT_ALREADY_USED` | OTT 이미 사용됨               | 중복 접속 방지 — 유저 킥      |
| `GAME_MISMATCH`    | OTT의 게임과 API Key의 게임 불일치 | 잘못된 OTT — 유저 킥       |
| `NOT_CHARGEABLE`   | 과금 불가 (G-coin 부족 등)      | 유저에게 G-coin 충전 안내    |

***

## 하트비트 — PATCH /v1/game/session/heartbeat

게임 세션이 활성 상태임을 서버에 알립니다. **2분(120초) 간격**으로 호출해야 합니다.

하트비트를 보내지 않으면 Redis TTL(4분) 만료로 세션이 자동 삭제됩니다.

### 요청

```lua theme={null}
local function sendHeartbeat(gameSessionId, providerUserId)
    local success, response = pcall(function()
        return HttpService:RequestAsync({
            Url = API_URL .. "/game/session/heartbeat",
            Method = "PATCH",
            Headers = {
                ["X-Api-Key"] = API_KEY,
                ["Content-Type"] = "application/json",
            },
            Body = HttpService:JSONEncode({
                game_session_id = gameSessionId,
                provider_user_id = providerUserId,
            }),
        })
    end)

    if not success then
        warn("[Playwave] Heartbeat request failed:", response)
        return nil
    end

    local decodeOk, body = pcall(function()
        return HttpService:JSONDecode(response.Body)
    end)

    if not decodeOk then
        return nil
    end

    return body
end
```

### 응답

```json theme={null}
{
  "success": true,
  "request_id": "req_...",
  "data": {
    "result": "OK",
    "next_heartbeat_in": 120,
    "play_duration_sec": 1800
  }
}
```

| 필드                  | 타입      | 설명                     |
| ------------------- | ------- | ---------------------- |
| `result`            | string  | 세션 상태                  |
| `next_heartbeat_in` | integer | 다음 하트비트까지 권장 대기 시간 (초) |
| `play_duration_sec` | integer | 서버 기준 누적 플레이 시간 (초)    |

### result 값과 처리

| result             | 설명        | 게임 서버 처리                         |
| ------------------ | --------- | -------------------------------- |
| `OK`               | 정상        | `next_heartbeat_in` 후 다음 하트비트 전송 |
| `CHARGE_EXHAUSTED` | G-coin 소진 | 유저에게 안내. 서버가 2분 유예 후 자동 종료       |

### 에러 응답

| HTTP | 코드                  | 조건            | 게임 서버 처리      |
| ---- | ------------------- | ------------- | ------------- |
| 404  | `SESSION_NOT_FOUND` | 세션 없음 (만료/삭제) | 하트비트 중지, 유저 킥 |
| 410  | `SESSION_ENDED`     | 이미 종료된 세션     | 하트비트 중지, 유저 킥 |
| 410  | `SESSION_ENDING`    | 종료 진행 중       | 하트비트 중지, 유저 킥 |

***

## 세션 종료 — DELETE /v1/game/session/end

플레이어가 게임을 떠날 때 호출합니다. 과금을 중지하고 세션을 정리합니다.

### 요청

```lua theme={null}
local function endSession(gameSessionId, playDurationSec)
    local requestBody = {
        game_session_id = gameSessionId,
    }

    if playDurationSec then
        requestBody.play_duration_sec = playDurationSec
    end

    local success, response = pcall(function()
        return HttpService:RequestAsync({
            Url = API_URL .. "/game/session/end",
            Method = "DELETE",
            Headers = {
                ["X-Api-Key"] = API_KEY,
                ["Content-Type"] = "application/json",
            },
            Body = HttpService:JSONEncode(requestBody),
        })
    end)

    if not success then
        warn("[Playwave] End session request failed:", response)
        return nil
    end

    local decodeOk, body = pcall(function()
        return HttpService:JSONDecode(response.Body)
    end)

    if not decodeOk then
        return nil
    end

    return body
end
```

### 응답

```json theme={null}
{
  "success": true,
  "request_id": "req_...",
  "data": {
    "result": "SUCCESS",
    "play_duration_sec": 1800,
    "client_play_duration_sec": 1795
  }
}
```

| 필드                         | 타입              | 설명                            |
| -------------------------- | --------------- | ----------------------------- |
| `result`                   | string          | 항상 `"SUCCESS"`                |
| `play_duration_sec`        | integer         | **서버 기준** 플레이 시간 (초) — 정산에 사용 |
| `client_play_duration_sec` | integer \| null | 요청에서 보낸 값. 미전달 시 `null`       |

### 에러 응답

| HTTP | 코드                  | 조건               | 게임 서버 처리    |
| ---- | ------------------- | ---------------- | ----------- |
| 404  | `SESSION_NOT_FOUND` | 세션 없음 (이미 만료/삭제) | 무시 (이미 정리됨) |
| 409  | `ALREADY_ENDED`     | 이미 종료된 세션        | 무시 (중복 호출)  |

***

## 전체 구현 예제

`ServerScriptService`에 아래 Script를 추가합니다.

```lua theme={null}
-- ServerScriptService/PlaywaveIntegration (Script)

local Players = game:GetService("Players")
local HttpService = game:GetService("HttpService")

-----------------------------------------------------------------
-- 설정
-----------------------------------------------------------------
local API_URL = "https://wave-api.playwave.dev/v1"
local API_KEY = "your-api-key-here"

local HEARTBEAT_INTERVAL = 120

-----------------------------------------------------------------
-- 플레이어별 세션 데이터
-----------------------------------------------------------------
local activeSessions = {}

-----------------------------------------------------------------
-- OTT 추출
-----------------------------------------------------------------
local function extractOtt(player)
    local success, joinData = pcall(function()
        return player:GetJoinData()
    end)

    if not success or not joinData then
        return nil
    end

    local launchData = joinData.LaunchData
    if not launchData or type(launchData) ~= "string" then
        return nil
    end

    return launchData
end

-----------------------------------------------------------------
-- API 호출 헬퍼
-----------------------------------------------------------------
local function apiRequest(method, path, body)
    local requestInfo = {
        Url = API_URL .. path,
        Method = method,
        Headers = {
            ["X-Api-Key"] = API_KEY,
            ["Content-Type"] = "application/json",
        },
    }

    if body then
        requestInfo.Body = HttpService:JSONEncode(body)
    end

    local success, response = pcall(function()
        return HttpService:RequestAsync(requestInfo)
    end)

    if not success then
        warn("[Playwave] HTTP request failed:", response)
        return nil, "NETWORK_ERROR"
    end

    if response.StatusCode >= 400 then
        local errorBody = nil
        pcall(function()
            errorBody = HttpService:JSONDecode(response.Body)
        end)
        return errorBody, "HTTP_" .. tostring(response.StatusCode)
    end

    local decodeSuccess, decoded = pcall(function()
        return HttpService:JSONDecode(response.Body)
    end)

    if not decodeSuccess then
        warn("[Playwave] JSON decode failed:", decoded)
        return nil, "DECODE_ERROR"
    end

    return decoded, nil
end

-----------------------------------------------------------------
-- OTT 검증 + 게임 세션 생성
-----------------------------------------------------------------
local function verifySession(player)
    local ott = extractOtt(player)
    if not ott then
        return nil
    end

    local result, err = apiRequest("POST", "/game/session/verify", {
        ott = ott,
        provider_user_id = tostring(player.UserId),
    })

    if err then
        warn("[Playwave] Verify failed for", player.Name, ":", err)
        return nil
    end

    if result and result.success and result.data then
        return result.data
    end

    return nil
end

-----------------------------------------------------------------
-- 하트비트 루프
-----------------------------------------------------------------
local function startHeartbeatLoop(userId, gameSessionId, providerUserId)
    return task.spawn(function()
        while activeSessions[userId] do
            task.wait(HEARTBEAT_INTERVAL)

            if not activeSessions[userId] then
                break
            end

            local result, err = apiRequest("PATCH", "/game/session/heartbeat", {
                game_session_id = gameSessionId,
                provider_user_id = providerUserId,
            })

            if err then
                if err == "HTTP_404" or err == "HTTP_410" then
                    warn("[Playwave] Session lost for UserId", userId, ":", err)
                    activeSessions[userId] = nil
                    local player = Players:GetPlayerByUserId(userId)
                    if player then
                        player:Kick("PlayWave session expired")
                    end
                    break
                end
                warn("[Playwave] Heartbeat error for UserId", userId, ":", err)
                continue
            end

            if result and result.data then
                local hbResult = result.data.result

                if hbResult == "CHARGE_EXHAUSTED" then
                    local player = Players:GetPlayerByUserId(userId)
                    if player then
                        warn("[Playwave] Charge exhausted for", player.Name)
                    end
                end
            end
        end
    end)
end

-----------------------------------------------------------------
-- 세션 종료
-----------------------------------------------------------------
local function endPlayerSession(userId)
    local session = activeSessions[userId]
    if not session then
        return
    end

    activeSessions[userId] = nil

    if session.heartbeatThread then
        task.cancel(session.heartbeatThread)
    end

    local clientPlayDuration = math.floor(os.clock() - session.startTime)

    local result, err = apiRequest("DELETE", "/game/session/end", {
        game_session_id = session.gameSessionId,
        play_duration_sec = clientPlayDuration,
    })

    if err then
        if err ~= "HTTP_404" and err ~= "HTTP_409" then
            warn("[Playwave] End session failed for UserId", userId, ":", err)
        end
    end
end

-----------------------------------------------------------------
-- 이벤트 연결
-----------------------------------------------------------------
Players.PlayerAdded:Connect(function(player)
    local data = verifySession(player)

    if not data or not data.is_valid then
        if data and data.reason then
            warn("[Playwave] Verify failed:", player.Name, data.reason)
        end
        return
    end

    local providerUserId = tostring(player.UserId)
    activeSessions[player.UserId] = {
        gameSessionId = data.game_session_id,
        startTime = os.clock(),
        heartbeatThread = nil,
    }

    activeSessions[player.UserId].heartbeatThread = startHeartbeatLoop(
        player.UserId,
        data.game_session_id,
        providerUserId
    )

    -- PC방 혜택 부여 (게임별 구현)
    if data.is_pc_cafe then
        grantPcCafeBonus(player)
    end
end)

Players.PlayerRemoving:Connect(function(player)
    endPlayerSession(player.UserId)
end)

game:BindToClose(function()
    for userId, _ in pairs(activeSessions) do
        endPlayerSession(userId)
    end
end)

-----------------------------------------------------------------
-- 게임별 혜택 로직 (직접 구현)
-----------------------------------------------------------------
function grantPcCafeBonus(player)
    -- 예: XP 부스트, 보너스 코인, 전용 아이템 등
    print("[Playwave] PC cafe bonus granted to", player.Name)
end
```

***

## 보안 주의사항

### API Key 보호

* API Key는 **반드시 `ServerScriptService`의 Script에서만** 사용
* `ReplicatedStorage`, `StarterPlayerScripts`, `LocalScript` 등 클라이언트 접근 가능한 곳에 절대 배치 금지
* `RemoteEvent`/`RemoteFunction`을 통해 API Key를 클라이언트에 전달 금지

### HttpService

* `HttpService:RequestAsync()`는 **ServerScript에서만 호출 가능** (Roblox 보안 정책)
* Game Settings → Security → **Allow HTTP Requests**를 반드시 활성화

### OTT

* OTT는 **1회용** — verify 호출 시 즉시 소비, 재사용 불가
* 발급 후 **1분(60초)** 이내에 사용해야 함
* OTT 형식: UUID (예: `a1b2c3d4-e5f6-7890-abcd-ef1234567890`)
