> ## 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.

# 수동 설치

> 플러그인 없이 PlayWave를 직접 설치하는 방법

<Note>
  이 가이드는 PlayWave 플러그인 없이 직접 설치하는 방법입니다. 플러그인을 사용하는 권장 방법은 [빠른 시작](/ko/roblox/quickstart) 가이드를 참고하세요.
</Note>

## 프로젝트 구조

<Tree>
  <Tree.Folder name="ServerScriptService" defaultOpen>
    <Tree.Folder name="Script" defaultOpen>
      <Tree.File name="PlaywaveService (ModuleScript)" />
    </Tree.Folder>
  </Tree.Folder>

  <Tree.Folder name="StarterPlayer" defaultOpen>
    <Tree.Folder name="StarterPlayerScripts" defaultOpen>
      <Tree.Folder name="Client (LocalScript)" defaultOpen>
        <Tree.File name="RedeemUI (ModuleScript)" />
      </Tree.Folder>
    </Tree.Folder>
  </Tree.Folder>
</Tree>

| 스크립트                | ClassName    | 역할                                                                     |
| ------------------- | ------------ | ---------------------------------------------------------------------- |
| **Script**          | Script       | 메인 서버 로직, PlaywaveService 초기화                                          |
| **PlaywaveService** | ModuleScript | API 통신, 세션 관리, <Tooltip tip="2분 간격으로 서버에 전송하는 세션 유지 신호">하트비트</Tooltip> |
| **Client**          | LocalScript  | 서버에 클라이언트 준비 신호 전송                                                     |
| **RedeemUI**        | ModuleScript | PC방 상태 표시 UI <Badge color="gray" size="sm">선택</Badge>                  |

## PlaywaveService 모듈

핵심 모듈입니다. API 통신, OTT 검증, 하트비트 루프, 세션 종료를 처리합니다.

<Warning>
  `API_URL`과 `API_KEY`를 발급받은 값으로 변경하세요. API Key는 서버 사이드에서만 사용해야 합니다.
</Warning>

### 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> |

```lua theme={null}
-- PlaywaveService (ModuleScript)
-- ServerScriptService/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  -- 2분

local activeSessions = {}

local PlaywaveService = {}

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 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 ok, decoded = pcall(function() return HttpService:JSONDecode(response.Body) end)
    if not ok then
        warn("[Playwave] JSON decode failed")
        return nil, "DECODE_ERROR"
    end
    return decoded, 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" or string.sub(err, 1, 7) == "HTTP_5" 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:", err)
                continue
            end

            if result and result.data and result.data.result == "CHARGE_EXHAUSTED" then
                local player = Players:GetPlayerByUserId(userId)
                if player then warn("[Playwave] Charge exhausted for", player.Name) 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 _, err = apiRequest("DELETE", "/game/session/end", {
        game_session_id = session.gameSessionId,
        play_duration_sec = clientPlayDuration,
    })
    if err and err ~= "HTTP_404" and err ~= "HTTP_409" then
        warn("[Playwave] End session failed for UserId", userId, ":", err)
    end
end

function PlaywaveService.init(clientReadyEvent, onPcCafe)
    clientReadyEvent.OnServerEvent:Connect(function(player)
        local joinData = player:GetJoinData()
        local ott = joinData.LaunchData

        if not ott or ott == "" then
            return
        end

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

        if err then
            warn("[Playwave] Verify error:", err)
            return
        end

        if result and result.success and result.data and result.data.is_valid then
            local data = result.data

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

            if data.is_pc_cafe and onPcCafe then
                onPcCafe(player)
            end
        else
            if result and result.data then
                warn("[Playwave] Verify failed:", player.Name, result.data.reason)
            end
        end
    end)

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

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

return PlaywaveService
```

## 서버 스크립트 (Script)

PlaywaveService를 초기화하고 게임 로직과 연결합니다.

```lua theme={null}
-- Script (ServerScriptService)
local PlaywaveService = require(script.PlaywaveService)
local ReplicatedStorage = game:GetService("ReplicatedStorage")

-- RemoteEvents 생성
local launchDataEvent = Instance.new("RemoteEvent")
launchDataEvent.Name = "LaunchDataEvent"
launchDataEvent.Parent = ReplicatedStorage

local clientReadyEvent = Instance.new("RemoteEvent")
clientReadyEvent.Name = "ClientReadyEvent"
clientReadyEvent.Parent = ReplicatedStorage

local respawnEvent = Instance.new("RemoteEvent")
respawnEvent.Name = "RespawnEvent"
respawnEvent.Parent = ReplicatedStorage

respawnEvent.OnServerEvent:Connect(function(player)
    player:LoadCharacterAsync()
end)

-- PlayWave 초기화
PlaywaveService.init(clientReadyEvent, function(player)
    launchDataEvent:FireClient(player, "PC_CAFE")
end)
```

## 클라이언트 스크립트 (LocalScript)

```lua theme={null}
-- Client (StarterPlayerScripts/LocalScript)
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RedeemUI = require(script.RedeemUI)

local redeem = RedeemUI.new()
redeem:start()

-- 서버에 준비 완료 신호 전송
local clientReadyEvent = ReplicatedStorage:WaitForChild("ClientReadyEvent")
clientReadyEvent:FireServer()
```

## RedeemUI 모듈 (선택)

PC방 인증 상태를 화면 오른쪽 상단에 표시하는 UI 모듈입니다.

<Accordion title="RedeemUI 전체 코드">
  ```lua theme={null}
  -- RedeemUI (ModuleScript)
  -- StarterPlayerScripts/Client 하위에 배치

  local Players = game:GetService("Players")
  local ReplicatedStorage = game:GetService("ReplicatedStorage")
  local TweenService = game:GetService("TweenService")

  local RedeemUI = {}
  RedeemUI.__index = RedeemUI

  function RedeemUI.new()
      local self = setmetatable({}, RedeemUI)
      self._player = Players.LocalPlayer
      return self
  end

  function RedeemUI:_build()
      local playerGui = self._player.PlayerGui

      local screenGui = Instance.new("ScreenGui")
      screenGui.Name = "RedeemUI"
      screenGui.ResetOnSpawn = false
      screenGui.IgnoreGuiInset = true
      screenGui.Parent = playerGui

      local frame = Instance.new("Frame")
      frame.Size = UDim2.fromOffset(220, 80)
      frame.Position = UDim2.new(1, 20, 0, 16)
      frame.AnchorPoint = Vector2.new(1, 0)
      frame.BackgroundColor3 = Color3.fromRGB(15, 10, 30)
      frame.BackgroundTransparency = 0.5
      frame.BorderSizePixel = 0
      frame.Parent = screenGui

      local corner = Instance.new("UICorner")
      corner.CornerRadius = UDim.new(0, 12)
      corner.Parent = frame

      local label = Instance.new("TextLabel")
      label.Size = UDim2.new(1, -16, 0.45, 0)
      label.Position = UDim2.fromOffset(10, 8)
      label.BackgroundTransparency = 1
      label.Text = "REDEEM CODE"
      label.TextColor3 = Color3.fromRGB(255, 255, 255)
      label.TextXAlignment = Enum.TextXAlignment.Left
      label.Font = Enum.Font.GothamBold
      label.TextSize = 13
      label.Parent = frame

      local codeLabel = Instance.new("TextLabel")
      codeLabel.Name = "CodeLabel"
      codeLabel.Size = UDim2.new(1, -16, 0.3, 0)
      codeLabel.Position = UDim2.new(0, 10, 0.45, 0)
      codeLabel.BackgroundTransparency = 1
      codeLabel.Text = "INACTIVE"
      codeLabel.TextColor3 = Color3.fromRGB(255, 255, 255)
      codeLabel.TextXAlignment = Enum.TextXAlignment.Left
      codeLabel.Font = Enum.Font.GothamBold
      codeLabel.TextSize = 16
      codeLabel.Parent = frame

      local userLabel = Instance.new("TextLabel")
      userLabel.Size = UDim2.new(1, -16, 0.25, 0)
      userLabel.Position = UDim2.new(0, 10, 0.72, 0)
      userLabel.BackgroundTransparency = 1
      userLabel.Text = "ID: " .. tostring(self._player.UserId)
      userLabel.TextColor3 = Color3.fromRGB(255, 255, 255)
      userLabel.TextXAlignment = Enum.TextXAlignment.Left
      userLabel.Font = Enum.Font.Gotham
      userLabel.TextSize = 13
      userLabel.Parent = frame

      TweenService:Create(frame, TweenInfo.new(0.4, Enum.EasingStyle.Back, Enum.EasingDirection.Out), {
          Position = UDim2.new(1, -16, 0, 16),
      }):Play()

      return frame, label, codeLabel
  end

  function RedeemUI:start()
      local frame, label, codeLabel = self:_build()

      local event = ReplicatedStorage:WaitForChild("LaunchDataEvent", 30)
      if not event then return end

      event.OnClientEvent:Connect(function(code)
          label.TextColor3 = Color3.fromRGB(0, 255, 200)
          codeLabel.Text = code
          codeLabel.TextColor3 = Color3.fromRGB(255, 255, 255)
      end)
  end

  return RedeemUI
  ```
</Accordion>

## 동작 요약

<Steps>
  <Step title="클라이언트 준비">
    LocalScript가 `ClientReadyEvent:FireServer()`로 서버에 준비 완료 신호를 보냅니다.
  </Step>

  <Step title="OTT 검증">
    PlaywaveService가 `GetJoinData().LaunchData`에서 OTT를 추출하고 서버에 검증 요청을 보냅니다.
  </Step>

  <Step title="세션 활성화">
    검증 성공 시 게임 세션을 저장하고 2분 간격 하트비트 루프를 시작합니다.
  </Step>

  <Step title="혜택 지급">
    `onPcCafe` 콜백이 호출되어 게임별 혜택 로직이 실행됩니다.
  </Step>

  <Step title="세션 종료">
    `PlayerRemoving` 또는 `BindToClose`에서 세션 종료 API를 호출합니다.
  </Step>
</Steps>
