
이번 글에서는 api 패키지에 속한 파일들에 대해 살펴보도록 하겠습니다.
이 글에서는 코드를 필요한 부분만 가져와 설명하거나, 설명을 위해 배치를 임의로 변경하기도 합니다.
원본 코드는 https://github.com/pagefaultgames/rogueserver.git 에서 확인하실 수 있습니다.

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메서드에서 호출됩니다.
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
}
uuidFromRequest는 tokenAndUuidFromRequest를, tokenAndUuidFromRequest는 tokenFromRequest를 호출하고 있습니다. 이 함수들은 사용자의 uuid를 얻는데 사용됩니다.
tokenFromRequest는 request의 header에 있는 Authorization값을 디코딩해 토큰값을 꺼냅니다.
tokenAndUuidFromRequest는 토큰을 조건으로 DB를 조회해 uuid를 꺼냅니다.
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형태로 클라이언트에게 반환합니다.
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에 직접 정의하지 않고 별도의 세부 패키지인 account, daily, savedata에 정의되어 있습니다. enpoints.go의 핸들러는 account, daily, savedata 패키지의 로직을 활용해 핸들러를 정의하고 있습니다.
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
}
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은 /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"`
}
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
}
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
}
}
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
}
}
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)
}
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") {
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)
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)
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)
{Valid = true, SystemData = nil}인 응답을 보냅니다. 유효하기 때문에 별다른 작업을 하지 않습니다. case "delete":
err := savedata.DeleteSystem(uuid)
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
}
}
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
}
이 스케줄러는 common.go에 정의된 Init메서드에 의해 시작됩니다.

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

