pokerogue 게임 서버 코드 분석하기 (5) - api 패키지 코드 분석

최창효·2025년 8월 14일
post-thumbnail

api 패키지 코드 살펴보기

이번 글에서는 api 패키지에 속한 파일들에 대해 살펴보도록 하겠습니다.

이 글에서는 코드를 필요한 부분만 가져와 설명하거나, 설명을 위해 배치를 임의로 변경하기도 합니다.
원본 코드는 https://github.com/pagefaultgames/rogueserver.git 에서 확인하실 수 있습니다.


common.go

Init

func Init(mux *http.ServeMux) error {
	err := scheduleStatRefresh()
	if err != nil {
		return err
	}

	err = daily.Init()
	if err != nil {
		return err
	}

	// account
	mux.HandleFunc("GET /account/info", handleAccountInfo)
    ...
    
    return nil
}

핸들러를 매핑합니다. 핸들러의 구체적인 로직은 endpoints.go파일에서 정의하고 있으며 Init메서드는 매핑만을 담당합니다.

api.go의 Init메서드는 routeserver.go의 main메서드에서 호출됩니다.

uuidFromRequest

func uuidFromRequest(r *http.Request) ([]byte, error) {
	_, uuid, err := tokenAndUuidFromRequest(r)
	if err != nil {
		return nil, err
	}

	return uuid, nil
}

func tokenAndUuidFromRequest(r *http.Request) ([]byte, []byte, error) {
	token, err := tokenFromRequest(r)
	if err != nil {
		return nil, nil, err
	}

	uuid, err := db.FetchUUIDFromToken(token)
	if err != nil {
		return nil, nil, fmt.Errorf("failed to validate token: %s", err)
	}

	return token, uuid, nil
}

func tokenFromRequest(r *http.Request) ([]byte, error) {
	if r.Header.Get("Authorization") == "" {
		return nil, fmt.Errorf("missing token")
	}

	token, err := base64.StdEncoding.DecodeString(r.Header.Get("Authorization"))
	if err != nil {
		return nil, fmt.Errorf("failed to decode token: %s", err)
	}

	if len(token) != account.TokenSize {
		return nil, fmt.Errorf("invalid token length: got %d, expected %d", len(token), account.TokenSize)
	}

	return token, nil
}

uuidFromRequesttokenAndUuidFromRequest를, tokenAndUuidFromRequesttokenFromRequest를 호출하고 있습니다. 이 함수들은 사용자의 uuid를 얻는데 사용됩니다.

tokenFromRequest는 request의 header에 있는 Authorization값을 디코딩해 토큰값을 꺼냅니다.

tokenAndUuidFromRequest는 토큰을 조건으로 DB를 조회해 uuid를 꺼냅니다.

writeJSON

func writeJSON(w http.ResponseWriter, r *http.Request, data any) {
	w.Header().Set("Content-Type", "application/json")
	err := json.NewEncoder(w).Encode(data)
	if err != nil {
		httpError(w, r, fmt.Errorf("failed to encode response json: %s", err), http.StatusInternalServerError)
		return
	}
}

writeJSON은 data를 Json형태로 클라이언트에게 반환합니다.

httpError

func httpError(w http.ResponseWriter, r *http.Request, err error, code int) {
	log.Printf("%s: %s\n", r.URL.Path, err)
	http.Error(w, err.Error(), code)
}

공통 에러처리 로직입니다.


endpoints.go

endpoints.go는 핸들러를 정의하고 있습니다.

여러 핸들러에서 사용되는 공통 로직이나, 특정 기능에 대한 세부적인 동작은 endpoints.go에 직접 정의하지 않고 별도의 세부 패키지인 account, daily, savedata에 정의되어 있습니다. enpoints.go의 핸들러는 account, daily, savedata 패키지의 로직을 활용해 핸들러를 정의하고 있습니다.

handleSession

handleSession은 /savedata/session/{action}으로의 요청을 담당하는 핸들러입니다.

게임의 '불러오기'화면에 접속하면 /savedata/session/{action} url로 요청을 보냅니다. 이때의 action은 GET이고 query parameter로 slot과 sessionId를 전달합니다.

func handleSession(w http.ResponseWriter, r *http.Request) {
	uuid, err := uuidFromRequest(r)
	if err != nil {
		httpError(w, r, err, http.StatusUnauthorized)
		return
	}

	slot, err := strconv.Atoi(r.URL.Query().Get("slot"))
	if err != nil {
		httpError(w, r, err, http.StatusBadRequest)
		return
	}

	if slot < 0 || slot >= defs.SessionSlotCount {
		httpError(w, r, fmt.Errorf("slot id %d out of range", slot), http.StatusBadRequest)
		return
	}

	if !r.URL.Query().Has("clientSessionId") {
		httpError(w, r, fmt.Errorf("missing clientSessionId"), http.StatusBadRequest)
		return
	}

	err = db.UpdateActiveSession(uuid, r.URL.Query().Get("clientSessionId"))
	if err != nil {
		httpError(w, r, fmt.Errorf("failed to update active session: %s", err), http.StatusBadRequest)
		return
	}
    
  • common.go에 정의된 uuidFromRequest메서드로 uuid를 획득합니다.
  • query parameter인 slot값을 추출하고 유효성을 검증합니다.
  • query parameter인 sessionId값을 추출하고 유효성을 검증합니다. 전달 받은 sessionId를 해당 유저(uuid)의 최신 ActiveSession으로 업데이트 합니다.
	switch r.PathValue("action") {
	case "get":
		save, err := savedata.GetSession(uuid, slot)
		if err != nil {
			if errors.Is(err, savedata.ErrSaveNotExist) {
				http.Error(w, err.Error(), http.StatusNotFound)
				return
			}

			httpError(w, r, err, http.StatusInternalServerError)
			return
		}

		writeJSON(w, r, save)
	case "update":
		var session defs.SessionSaveData
		err = json.NewDecoder(r.Body).Decode(&session)
		if err != nil {
			httpError(w, r, fmt.Errorf("failed to decode request body: %s", err), http.StatusBadRequest)
			return
		}

		existingSave, err := savedata.GetSession(uuid, slot)
		if err != nil {
			if !errors.Is(err, savedata.ErrSaveNotExist) {
				httpError(w, r, fmt.Errorf("failed to retrieve session save data: %s", err), http.StatusInternalServerError)
				return
			}
		} else {
			if existingSave.Seed == session.Seed && existingSave.WaveIndex > session.WaveIndex {
				httpError(w, r, fmt.Errorf("session out of date: existing wave index is greater"), http.StatusBadRequest)
				return
			}
		}

		err = savedata.UpdateSession(uuid, slot, session)
		if err != nil {
			httpError(w, r, fmt.Errorf("failed to put session data: %s", err), http.StatusInternalServerError)
			return
		}

		w.WriteHeader(http.StatusOK)
	case "clear":
		var session defs.SessionSaveData
		err = json.NewDecoder(r.Body).Decode(&session)
		if err != nil {
			httpError(w, r, fmt.Errorf("failed to decode request body: %s", err), http.StatusBadRequest)
			return
		}

		seed, err := db.GetDailyRunSeed()
		if err != nil {
			httpError(w, r, err, http.StatusInternalServerError)
			return
		}

		resp, err := savedata.Clear(uuid, slot, seed, session)
		if err != nil {
			httpError(w, r, err, http.StatusInternalServerError)
			return
		}

		writeJSON(w, r, resp)
	case "newclear":
		resp, err := savedata.NewClear(uuid, slot)
		if err != nil {
			httpError(w, r, fmt.Errorf("failed to read new clear: %s", err), http.StatusInternalServerError)
			return
		}

		writeJSON(w, r, resp)
	case "delete":
		err := savedata.DeleteSession(uuid, slot)
		if err != nil {
			httpError(w, r, err, http.StatusInternalServerError)
			return
		}

		w.WriteHeader(http.StatusOK)
	default:
		httpError(w, r, fmt.Errorf("unknown action"), http.StatusBadRequest)
		return
	}
}
  • action이 어떤 값이냐에 따라 다르게 동작합니다. 불러오기 화면에서 전달한 action인 GET의 경우 savedata.GetSession(uuid, slot)으로 해당 유저의 대상 슬롯에 저장된 데이터를 가져와 body에 담아 반환(writeJSON(w,r,save))합니다.

  • 이때 반환하는 값은 SessionSaveData타입입니다. SessionSaveData는 이전에 플레이하던 게임에 대한 정보를 모두 담고 있습니다.

    type SessionSaveData struct {
        Seed                     string                   `json:"seed"`
        PlayTime                 int                      `json:"playTime"`
        GameMode                 GameMode                 `json:"gameMode"`
        Party                    []PokemonData            `json:"party"`
        EnemyParty               []PokemonData            `json:"enemyParty"`
        Modifiers                []PersistentModifierData `json:"modifiers"`
        EnemyModifiers           []PersistentModifierData `json:"enemyModifiers"`
        Arena                    ArenaData                `json:"arena"`
        PokeballCounts           PokeballCounts           `json:"pokeballCounts"`
        Money                    int                      `json:"money"`
        Score                    int                      `json:"score"`
        VictoryCount             int                      `json:"victoryCount"`
        FaintCount               int                      `json:"faintCount"`
        ReviveCount              int                      `json:"reviveCount"`
        WaveIndex                int                      `json:"waveIndex"`
        BattleType               BattleType               `json:"battleType"`
        Trainer                  TrainerData              `json:"trainer"`
        GameVersion              string                   `json:"gameVersion"`
        Timestamp                int                      `json:"timestamp"`
        Challenges               []ChallengeData          `json:"challenges"`
        MysteryEncounterType     MysteryEncounterType     `json:"mysteryEncounterType"`
        MysteryEncounterSaveData MysteryEncounterSaveData `json:"mysteryEncounterSaveData"`
    }

handleUpdateAll

handleUpdateAll은 /savedata/updateall으로의 요청을 담당하는 핸들러입니다.

게임을 진행하다보면 중간중간 /savedata/updateall요청을 보냅니다. (매 단계마다 보내는건 아니고 5스테이지 정도 마다 요청을 보냅니다)

이 요청의 body에는 다음과 같은 데이터가 담겨 있습니다.

handleUpdateAll은 request body에 담긴 데이터를 CombinedSaveData타입으로 처리합니다.

type CombinedSaveData struct {
	System          defs.SystemSaveData  `json:"system"`
	Session         defs.SessionSaveData `json:"session"`
	SessionSlotId   int                  `json:"sessionSlotId"`
	ClientSessionId string               `json:"clientSessionId"`
}
  • SystemSaveData는 플레이어(캐릭터 성별이 뭔지, 알 현황이 어떻게 되는지, 어떤걸 해금했는지 등)에 대한 정보입니다.
  • SessionSaveData는 현재 플레이중인 판(어떤 모드인지, 어떤 포켓몬들로 플레이하고 있는지, 몇층인지 등)에 대한 정보입니다.
func handleUpdateAll(w http.ResponseWriter, r *http.Request) {
	uuid, err := uuidFromRequest(r)
	if err != nil {
		httpError(w, r, err, http.StatusUnauthorized)
		return
	}

	var data CombinedSaveData
	err = json.NewDecoder(r.Body).Decode(&data)
	if err != nil {
		httpError(w, r, fmt.Errorf("failed to decode request body: %s", err), http.StatusBadRequest)
		return
	}

	if data.ClientSessionId == "" {
		httpError(w, r, fmt.Errorf("missing clientSessionId"), http.StatusBadRequest)
		return
	}

	active, err := db.IsActiveSession(uuid, data.ClientSessionId)
	if err != nil {
		httpError(w, r, fmt.Errorf("failed to check active session: %s", err), http.StatusBadRequest)
		return
	}

	if !active {
		httpError(w, r, fmt.Errorf("session out of date: not active"), http.StatusBadRequest)
		return
	}
    
  • common.go에 정의된 uuidFromRequest메서드로 uuid를 획득합니다.
  • request의 body에 있는 데이터를 가져와 CombinedSaveData타입의 변수에 할당합니다.
  • request로 전달받은 세션이 activeSession인지 확인합니다.
	storedTrainerId, storedSecretId, err := db.FetchTrainerIds(uuid)
	if err != nil {
		httpError(w, r, err, http.StatusInternalServerError)
		return
	}

	if storedTrainerId > 0 || storedSecretId > 0 {
		if data.System.TrainerId != storedTrainerId || data.System.SecretId != storedSecretId {
			httpError(w, r, fmt.Errorf("session out of date: stored trainer or secret ID does not match"), http.StatusBadRequest)
			return
		}
	} else {
		err = db.UpdateTrainerIds(data.System.TrainerId, data.System.SecretId, uuid)
		if err != nil {
			httpError(w, r, err, http.StatusInternalServerError)
			return
		}
	}
  • DB에서 해당 유저(uuid)의 trainerId(storedTrainerId)와 secretId(storedSecretId)를 조회합니다.
  • storeTrainerId와 storedSecretId가 0 이하면 해당 요청에서 사용자로부터 받은 data의 trainerId와 secretId로 값을 update합니다. trainerId와 secretId는 기본값이 0이고 음수로 저장하는 경우가 없습니다. 따라서 이전에 trainerId와 secretId를 저장한 적 없을 때를 의미합니다.
  • storedTrainerId와 storedSecretId가 0보다 크다면(기본값이 아니라면) 이때는 DB에 저장된 값과 해당 요청에서 사용자로부터 받은 값이 일치해야 합니다.
	oldSystem, err := savedata.GetSystem(uuid)
	if err != nil {
		if !errors.Is(err, savedata.ErrSaveNotExist) {
			httpError(w, r, fmt.Errorf("failed to retrieve playtime: %s", err), http.StatusInternalServerError)
			return
		}
	} else {
		playtime, ok := data.System.GameStats.(map[string]interface{})["playTime"].(float64)
		if !ok {
			httpError(w, r, fmt.Errorf("no playtime found"), http.StatusBadRequest)
			return
		}

		oldPlaytime, ok := oldSystem.GameStats.(map[string]interface{})["playTime"].(float64)
		if !ok {
			httpError(w, r, fmt.Errorf("no playtime found"), http.StatusBadRequest)
			return
		}

		if playtime < oldPlaytime {
			httpError(w, r, fmt.Errorf("session out of date: existing playtime is greater"), http.StatusBadRequest)
			return
		}
	}
  • DB에 저장되어 있는 SystemSaveData를 조회합니다.(oldSystem)
  • 해당 요청에서 사용자로부터 받은 playtime과 oldSystem의 playtime을 비교합니다. 방금 받은 playtime이 DB에 저장되어 있던 playtime(oldPlaytime)보다 작다면 에러를 반환합니다. (정상적으로 플레이했으면 DB에 저장된 플레이타임보다 방금까지 한 플레이타임이 더 커야함)
	existingSave, err := savedata.GetSession(uuid, data.SessionSlotId)
	if err != nil {
		if !errors.Is(err, savedata.ErrSaveNotExist) {
			httpError(w, r, fmt.Errorf("failed to retrieve session save data: %s", err), http.StatusInternalServerError)
			return
		}
	} else {
		if existingSave.Seed == data.Session.Seed && existingSave.WaveIndex > data.Session.WaveIndex {
			httpError(w, r, fmt.Errorf("session out of date: existing wave index is greater"), http.StatusBadRequest)
			return
		}
	}

	err = savedata.Update(uuid, data.SessionSlotId, data.Session)
	if err != nil {
		httpError(w, r, err, http.StatusInternalServerError)
		return
	}

	err = savedata.Update(uuid, 0, data.System)
	if err != nil {
		httpError(w, r, err, http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusOK)
}
  • DB에 저장되어 있는 SessionSaveData를 조회합니다.(existingSave)
  • 해당 요청에서 사용자로부터 받은 SessionSaveData와 DB에서 조회한 SessionSaveData를 비교합니다. Seed가 같은데 DB에서 조회한 WaveIndex가 더 크다면 에러를 반환합니다. (정상적으로 플레이했으면 DB에 저장된 WaveIndex(층)보다 방금까지 한 WaveIndex(층)가 더 커야함)

handleSystem

handleSystem은 /savedata/system/{action}으로의 요청을 담당하는 핸들러입니다.

매 층을 통과할 때마다 action이 verify인 /savedata/system/{action}요청을 보냅니다.

func handleSystem(w http.ResponseWriter, r *http.Request) {
	uuid, err := uuidFromRequest(r)
	if err != nil {
		httpError(w, r, err, http.StatusUnauthorized)
		return
	}

	if !r.URL.Query().Has("clientSessionId") {
		httpError(w, r, fmt.Errorf("missing clientSessionId"), http.StatusBadRequest)
		return
	}

	active, err := db.IsActiveSession(uuid, r.URL.Query().Get("clientSessionId"))
	if err != nil {
		httpError(w, r, fmt.Errorf("failed to check active session: %s", err), http.StatusBadRequest)
		return
	}
	switch r.PathValue("action") {
  • 사용자가 전달한 session이 activeSession인지 확인합니다.
  • action이 어떤 값이냐에 따라 다르게 동작합니다.
	case "get":
		if !active {
			err = db.UpdateActiveSession(uuid, r.URL.Query().Get("clientSessionId"))
			if err != nil {
				httpError(w, r, fmt.Errorf("failed to update active session: %s", err), http.StatusBadRequest)
				return
			}
		}

		save, err := savedata.GetSystem(uuid)
		if err != nil {
			if errors.Is(err, savedata.ErrSaveNotExist) {
				http.Error(w, err.Error(), http.StatusNotFound)
				return
			}

			httpError(w, r, fmt.Errorf("failed to get system save data: %s", err), http.StatusInternalServerError)
			return
		}

		writeJSON(w, r, save)
  • action이 GET일 경우
    • activeSession이 없다면 db.UpdateActiveSession을 호출합니다. 메서드는 Update이지만 실제 내부 쿼리는 Upsert(있으면 Update, 없으면 Insert)기 때문에 activeSession을 새롭게 Insert합니다.
    • SystemSaveData를 조회해 사용자에게 반환합니다.
	case "update":
		if !active {
			httpError(w, r, fmt.Errorf("session out of date: not active"), http.StatusBadRequest)
			return
		}

		var system defs.SystemSaveData
		err = json.NewDecoder(r.Body).Decode(&system)
		if err != nil {
			httpError(w, r, fmt.Errorf("failed to decode request body: %s", err), http.StatusBadRequest)
			return
		}

		oldSystem, err := savedata.GetSystem(uuid)
		if err != nil {
			if !errors.Is(err, savedata.ErrSaveNotExist) {
				httpError(w, r, fmt.Errorf("failed to retrieve playtime: %s", err), http.StatusInternalServerError)
				return
			}
		} else {
			playtime, ok := system.GameStats.(map[string]interface{})["playTime"].(float64)
			if !ok {
				httpError(w, r, fmt.Errorf("no playtime found"), http.StatusBadRequest)
				return
			}

			oldPlaytime, ok := oldSystem.GameStats.(map[string]interface{})["playTime"].(float64)
			if !ok {
				httpError(w, r, fmt.Errorf("no playtime found"), http.StatusBadRequest)
				return
			}

			if playtime < oldPlaytime {
				httpError(w, r, fmt.Errorf("session out of date: existing playtime is greater"), http.StatusBadRequest)
				return
			}
		}

		err = savedata.UpdateSystem(uuid, system)
		if err != nil {
			httpError(w, r, fmt.Errorf("failed to put system data: %s", err), http.StatusInternalServerError)
			return
		}

		w.WriteHeader(http.StatusNoContent)
  • action이 UPDATE일 경우
    • DB에 저장되어 있는 SystemSaveData와 해당 요청에서 사용자로부터 받은 SystemSaveData의 playtime을 비교합니다. 정상(playtime >= oldPlaytime)일 경우 사용자로부터 받은 SystemSaveData로 DB를 update합니다. (handleUpdateAll에서의 방식과 유사합니다)
	case "verify":
		response := SystemVerifyResponse{
			Valid: active,
		}

		// not valid, send server state
		if !active {
			err := db.UpdateActiveSession(uuid, r.URL.Query().Get("clientSessionId"))
			if err != nil {
				httpError(w, r, fmt.Errorf("failed to update active session: %s", err), http.StatusBadRequest)
				return
			}

			storedSaveData, err := db.ReadSystemSaveData(uuid)
			if err != nil {
				httpError(w, r, fmt.Errorf("failed to read session save data: %s", err), http.StatusInternalServerError)
				return
			}

			response.SystemData = storedSaveData
		}

		writeJSON(w, r, response)
  • action이 VERIFY일 경우
    • 사용자가 보낸 Session이 activeSession이면 {Valid = true, SystemData = nil}인 응답을 보냅니다. 유효하기 때문에 별다른 작업을 하지 않습니다.
    • 사용자가 보낸 Session이 activeSession이 아니면 db.UpdateActiveSession을 호출합니다. 그리고 DB에 저장된 사용자의 SystemData를 응답값에 담아 클라이언트에 전달합니다.
	case "delete":
		err := savedata.DeleteSystem(uuid)
		if err != nil {
			httpError(w, r, err, http.StatusInternalServerError)
			return
		}

		w.WriteHeader(http.StatusOK)
  • action이 DELETE일 경우 사용자의 SystemSaveData를 삭제합니다.
	default:
		httpError(w, r, fmt.Errorf("unknown action"), http.StatusBadRequest)
		return
	}
}
  • GET, UPDATE, VERIFY, DELETE이외의 action일 경우 에러를 반환합니다.

stats.go

var (
	scheduler           = cron.New(cron.WithLocation(time.UTC))
	playerCount         int
	battleCount         int
	classicSessionCount int
)

func scheduleStatRefresh() error {
	_, err := scheduler.AddFunc("@every 30s", func() {
		err := updateStats()
		if err != nil {
			log.Printf("failed to update stats: %s", err)
		}
	})
	if err != nil {
		return err
	}

	scheduler.Start()
	return nil
}

func updateStats() error {
	var err error
	playerCount, err = db.FetchPlayerCount()
	if err != nil {
		return err
	}

	battleCount, err = db.FetchBattleCount()
	if err != nil {
		return err
	}

	classicSessionCount, err = db.FetchClassicSessionCount()
	if err != nil {
		return err
	}

	return nil
}
  • playerCount, battleCount, classicSessionCount를 30초마다 갱신하는 스케줄러를 실행합니다.

이 스케줄러는 common.go에 정의된 Init메서드에 의해 시작됩니다.

/game/titlestats요청이 오면 handleGameTitleStats핸들러가 스케줄러에 의해 갱신된 playerCount, battleCount값을 반환합니다.


profile
기록하고 정리하는 걸 좋아하는 백엔드 개발자입니다.

0개의 댓글