ゲーム開発におけるログインボーナスAPIの仕様

# はじめに
この記事は[サイバーエージェント24卒内定者 Advent Calendar](https://qiita.com/advent-calendar/2023/ca24engineer)の18日目です。

こんにちは、都内在住の大学生をしている[Daina](https://qiita.com/nishiumidaina)です！普段はゲームの開発や研究をしています。
今回はゲームのサーバーサイド開発に焦点を当てて、ログインボーナスAPIのロジックや実装方法に関する内容を中心に取り扱います！

## プロジェクト構成
- 言語：Golang
- フレームワーク: Echo
- DB: MySQL
- ORM: gorm
- キャッシュ：go-cache

※重要なディレクトリのみ記載
```
.
├─ api
|  ├─ di: wire
|  └─ presentation
|     ├─ controller: ハンドリングを記述する
|     ├─ middleware
|     ├─ request: 自動生成
|     ├─ response: 自動生成
|     └─ router: ルーティングを記述する
├─ docs
|  ├─ api: yamlを記述するとrequest, responseが自動生成される
|  ├─ entity: yamlを記述するとentity, repository, daoが自動生成される
│  └─ enum: yamlを記述するとenumが自動生成される
├─ domain
|  ├─ entity: 自動生成
|  ├─ enum: 自動生成
|  ├─ repository: 自動生成
│  └─ service: ここにロジックを記述する
└─ infra
   └─ dao: 自動生成
```


- 本記事で扱うコード

https://github.com/game-core/gocrafter

# ログインボーナスってなに？
普段ゲームをやる方なら説明不要かも知れませんが...

ログインボーナスとは、日付やイベントによってスケジューリングされた内容の報酬を受け取れる機能です。ユーザーが1日に１回（またはイベント中）にログインすると条件に応じて報酬を受け取れるといった機能です。

## 1.ログインボーナスの構成要素
ログインボーナスはゲーム開発の中でも多くの要素が絡む機能です。最もシンプルな構成にしたとしても下記のような機能の実装が必要となります。

- ユーザーの状態を判定する機能（ログイン何日目か？）
- アイテムの受け取り機能（アイテムBOXやアクション機能）
- アイテムの配布設定
- イベントのスケジューリング機能

上記の要素以外にも課金ユーザーやボーナスアイテムの適応など、ゲームの構成によっては機能の複雑化をせざるを得ない部分でもあります。

## 2.開発する時のポイント
これはあくまで私の実体験に基づいた個人的な意見です！

#### 他の機能に依存している処理はService単位で依存関係を持たせる
例えばマスターデータからアイテムを取得して報酬として受け取る場合、ログインボーナスのサービスからアイテムのRepositoryを呼ぶのではなく、アイテムのServiceから取得するべきです。

ログインボーナスのServiceは、見た目の機能量以上に処理が複雑化しやすい為、他の構成要素をログインボーナスのServiceロジックに入れてしまうのはお勧めしません。また、ログインボーナスに限らず、構成要素の多い機能を開発する際は、Service同士の依存関係を持たせることにより、コード全体の記述量を少なくすることができます。

#### 細かい計算処理や設定はEntity(Model)ロジックに分散させる
これも上記と理由は同じで、Serviceの肥大化を分散する為です。例えばログイン日数の計算や２重受け取り防止の処理などは、Entity単体と現在日時の情報で完結します。

ゲーム開発の場合、こうした一見意味のわからない計算ロジックが至る所で発生する為、複数の要素が入っているServiceに脳死で計算ロジックを書かないことをお勧めします。また、計算ロジックを外部に切り出すことでテストの網羅性を高めることができます。

## 3.実装
それでは実際にログインボーナスのサーバーサイドを実装例を解説します。

#### 1.自動生成用のyamlを定義
※一部のみ記載

ドメインロジックに必要なentity, repository, daoを自動生成してくれます。
```yaml
# github.com/game-core/gocrafter/blob/main/docs/entity/master/LoginRewardModel.yaml
# ログインボーナスの設定
name: LoginRewardModel
package: loginReward
structure:
  ID:
    name: id
    type: int64
    nullable: false
    number: 1
  Name:
    name: name
    type: string
    nullable: false
    number: 2
  EventName:
    name: event_name
    type: string
    nullable: false
    number: 3
  CreatedAt:
    name: created_at
    type: time.Time
    nullable: false
    number: 4
  UpdatedAt:
    name: updated_at
    type: time.Time
    nullable: false
    number: 5
primary:
  - ID
index:
  - Name
  - EventName
  - Name,EventName
```
```yaml
# game-core/gocrafter/blob/main/docs/entity/master/LoginRewardReward.yaml
# ログインボーナスの報酬設定
name: LoginRewardReward
package: loginReward
structure:
  ID:
    name: id
    type: int64
    nullable: false
    number: 1
  LoginRewardModelName:
    name: login_reward_model_name
    type: string
    nullable: false
    number: 2
  Name:
    name: name
    type: string
    nullable: false
    number: 3
  StepNumber:
    name: step_number
    type: int
    nullable: false
    number: 4
  Items:
    name: items
    type: string
    number: 5
  CreatedAt:
    name: created_at
    type: time.Time
    nullable: false
    number: 6
  UpdatedAt:
    name: updated_at
    type: time.Time
    nullable: false
    number: 7
primary:
  - ID
index:
  - Name
  - LoginRewardModelName
```

アプリケーションロジックに必要なrequestとresponseを自動生成してくれます。
```yaml
# game-core/gocrafter/blob/main/docs/api/request/loginReward/ReceiveLoginReward.yaml
# ログイン報酬の受け取りリクエスト
name: ReceiveLoginReward
package: loginReward
structure:
  ShardKey:
    name: shard_key
    type: string
    nullable: false
    number: 1
  AccountID:
    name: account_id
    type: int64
    nullable: false
    number: 2
  UUID:
    name: uuid
    type: string
    nullable: false
    number: 3
  LoginRewardModelName:
    name: login_reward_model_name
    type: string
    nullable: false
    number: 4
```
```yaml
# game-core/gocrafter/blob/main/docs/api/response/loginReward/ReceiveLoginReward.yaml
# ログイン報酬の受け取りレスポンス
name: LoginRewardReward
package: loginReward
structure:
  ID:
    name: id
    type: int64
    nullable: false
    number: 1
  Name:
    name: name
    type: string
    nullable: false
    number: 2
  StepNumber:
    name: step_number
    type: int
    nullable: false
    number: 3
  Items:
    name: items
    type: Items
    nullable: false
    number: 4
```
他にもアイテムやスケジュールの設定が必要ですが説明は省略します。
以下のyamlを全て作成することでcontroller, service, di以外のレイヤーを自動生成します。

- ドメインロジック（entity, repository, dao）
[イベント](https://github.com/game-core/gocrafter/blob/main/docs/entity/master/Event.yaml)
[アイテム](https://github.com/game-core/gocrafter/blob/main/docs/entity/master/Item.yaml)
[アイテムボックス（報酬の保存先）](https://github.com/game-core/gocrafter/blob/main/docs/entity/user/ItemBox.yaml)
[ログインボーナスの設定](https://github.com/game-core/gocrafter/blob/main/docs/entity/master/LoginRewardModel.yaml)
[ログインボーナスのアイテム設定](https://github.com/game-core/gocrafter/blob/main/docs/entity/master/LoginRewardItem.yaml)
[ログインボーナスの報酬設定](https://github.com/game-core/gocrafter/blob/main/docs/entity/master/LoginRewardReward.yaml)
[ログインボーナスのユーザーステータス](https://github.com/game-core/gocrafter/blob/main/docs/entity/user/LoginRewardStatus.yaml)

- アプリケーションロジック（request, response）
[ログインボーナスの受け取りリクエスト](https://github.com/game-core/gocrafter/blob/main/docs/api/request/loginReward/ReceiveLoginReward.yaml)
[ログインボーナスの受け取りレスポンス](https://github.com/game-core/gocrafter/blob/main/docs/api/response/loginReward/ReceiveLoginReward.yaml)
[イベント（レスポンス用）](https://github.com/game-core/gocrafter/blob/main/docs/api/response/loginReward/Event.yaml)
[ログインボーナスのアイテム設定（レスポンス用）](https://github.com/game-core/gocrafter/blob/main/docs/api/response/loginReward/Item.yaml)
[ログインボーナスの設定（レスポンス用）](https://github.com/game-core/gocrafter/blob/main/docs/api/response/loginReward/LoginRewardModel.yaml)
[ログインボーナスの報酬設定（レスポンス用）](https://github.com/game-core/gocrafter/blob/main/docs/api/response/loginReward/LoginRewardReward.yaml)
[ログインボーナスのユーザーステータス（レスポンス用）](https://github.com/game-core/gocrafter/blob/main/docs/api/response/loginReward/LoginRewardStatus.yaml)

生成されたコードについては[ゲーム開発におけるのサーバー負荷と戦う工夫の話]()で一部解説しているため、興味がある方はご覧ください。

#### 2.Serviceロジックを追加する
続いて自動生成したドメインロジックにServiceロジックを追加して、ログインボーナスの具体的な処理を記述していきます。

少々長く見えますがtransactionとresponseのセッターは定型文なので実際には3ステップの構成となっています。
```go
// github.com/game-core/gocrafter/blob/main/domain/service/api/loginReward/loginReward_service.go
// ReceiveLoginReward 受け取る
func (s *loginRewardService) ReceiveLoginReward(req *request.ReceiveLoginReward, now time.Time) (*response.ReceiveLoginReward, error) {
	// transaction
	tx, err := s.transactionRepository.Begin(req.ShardKey)
	if err != nil {
		return nil, err
	}
	defer func() {
		if err := s.transactionRepository.CommitOrRollback(tx, err); err != nil {
			log.Panicln(err)
		}
	}()

	lrm, lrrs, e, err := s.getLoginRewardModelAndRewardsAndEvent(req.LoginRewardModelName, now)
	if err != nil {
		return nil, err
	}

	lrs, err := s.loginRewardStatusRepository.FindOrNilByLoginRewardModelName(lrm.Name, req.ShardKey)
	if err != nil {
		return nil, err
	}

	lrs, err = s.receive(lrs, lrrs, e, req, now, tx)
	if err != nil {
		return nil, err
	}

	rewards, err := response.ToRewards(lrrs)
	if err != nil {
		return nil, err
	}

	items, err := response.ToItems(lrrs.GetItems(e.GetDayCount(now)))
	if err != nil {
		return nil, err
	}

	return response.ToReceiveLoginReward(
		200,
		*response.ToLoginRewardStatus(
			lrs.ID,
			*response.ToLoginRewardModel(
				lrm.ID,
				lrm.Name,
				*response.ToEvent(
					e.ID,
					e.Name,
					e.ResetHour,
					e.RepeatSetting,
					e.RepeatStartAt,
					e.StartAt,
					e.EndAt,
				),
				rewards,
			),
			items,
			lrs.LastReceivedAt,
		),
	), nil
}
```


下記が主要な3ステップの処理です。基本的には
マスターデータとユーザーデータを取得して各情報を更新するだけです。

余談ですが、この時マスターデータはサーバー内メモリにキャッシュされているため、実際にはDBの参照は行われていません。
```go
    // マスターデータを取得
	lrm, lrrs, e, err := s.getLoginRewardModelAndRewardsAndEvent(req.LoginRewardModelName, now)
	if err != nil {
		return nil, err
	}

    // ユーザーステータスを取得
	lrs, err := s.loginRewardStatusRepository.FindOrNilByLoginRewardModelName(lrm.Name, req.ShardKey)
	if err != nil {
		return nil, err
	}

    // 報酬を受け取り
	lrs, err = s.receive(lrs, lrrs, e, req, now, tx)
	if err != nil {
		return nil, err
	}
		
```

続いて切り出されたreceive()メソッド解説します。

こちらのメソッドはログインボーナスの軸となる報酬の受け取りとステータスの更新を行うメソッドです。また、受け取る前に二重受け取り防止のロジック（HasReceived()メソッド）を記述します。
```go
// github.com/game-core/gocrafter/blob/main/domain/service/api/loginReward/loginReward_service.go
// receive 受け取り
func (s *loginRewardService) receive(lrs *userLoginRewardEntity.LoginRewardStatus, lrrs *masterLoginRewardEntity.LoginRewardRewards, e *masterEventEntity.Event, req *request.ReceiveLoginReward, now time.Time, tx *gorm.DB) (*userLoginRewardEntity.LoginRewardStatus, error) {
	if lrs != nil && !lrs.HasReceived(now, e.ResetHour) {
		return nil, errors.New("already received")
	}

	if err := s.receiveItem(lrrs, e, now, req.AccountID, req.ShardKey); err != nil {
		return nil, err
	}

	res, err := s.updateLoginRewardStatus(lrs, now, req.LoginRewardModelName, req.AccountID, req.ShardKey, tx)
	if err != nil {
		return nil, err
	}

	return res, nil
}
```

最後にEntityに日数計算や報酬アイテムの取得ロジックを記述します。
```go
// HasReceived 報酬を受け取っているか
func (e *LoginRewardStatus) HasReceived(now time.Time, resetHour int) bool {
	resetTime := time.Date(now.Year(), now.Month(), now.Day(), resetHour, 0, 0, 0, now.Location())
	if now.Before(resetTime) {
		return e.LastReceivedAt.Add(24 * time.Hour).Before(now)
	}

	return e.LastReceivedAt.Before(resetTime)
}

```
```go
// GetMaxStepNumber ステップナンバーの最大値を取得
func (es *LoginRewardRewards) GetMaxStepNumber() (maxStepNumber int) {
	for _, rewards := range *es {
		if rewards.StepNumber > maxStepNumber {
			maxStepNumber = rewards.StepNumber
		}
	}

	return maxStepNumber
}

// GetItems アイテムを取得
func (es *LoginRewardRewards) GetItems(dayCount int) (items string) {
	maxStepNumber := es.GetMaxStepNumber()
	if maxStepNumber < dayCount && maxStepNumber > 0 {
		dayCount %= maxStepNumber
	}

	for _, rewards := range *es {
		if dayCount == rewards.StepNumber {
			items = rewards.Items
		}
	}

	return items
}
```

```go
// GetDayCount イベントの経過日数を取得
func (e *Event) GetDayCount(now time.Time) int {
	if e.RepeatSetting {
		return times.GetDayCount(*e.RepeatStartAt, now)
	}

	return times.GetDayCount(*e.StartAt, now)
}

// IsEventPeriod イベント期間中か
func (e *Event) IsEventPeriod(now time.Time) bool {
	// イベント開始前の場合 
    if e.StartAt != nil && e.StartAt.After(now) {
		return false
	}

	// イベント終了後の場合
	if e.EndAt != nil && e.EndAt.Before(now) {
		return false
	}

	// 定常イベント開始前の場合
	if e.RepeatSetting && e.RepeatStartAt.After(now) {
		return false
	}

	return true
}
```

#### 3.APIを叩いてみる
- リクエスト
http://localhost:8001/login_reward/receive_login_reward
```json
{
    "id": 1,
    "shard_key": "SHARD_1",
    "uuid": "woqV4BnZhpvrcToBCUyg",
    "login_reward_model_name": "Model1"
}
```
- レスポンス
```json
{
    "status": 200,
    "login_reward_status": {
        "id": 1,
        "login_reward_model": {
            "id": 1,
            "name": "Model1",
            "event": {
                "id": 1,
                "name": "Event1",
                "reset_hour": 9,
                "repeat_setting": true,
                "repeat_start_at": "2023-11-23T08:00:00+09:00",
                "start_at": null,
                "end_at": null
            },
            "login_reward_rewards": [
                {
                    "id": 1,
                    "name": "Reward1",
                    "step_number": 0,
                    "items": [
                        {
                            "name": "Item0",
                            "count": 2
                        },
                        {
                            "name": "Item1",
                            "count": 5
                        }
                    ]
                },
                {
                    "id": 2,
                    "name": "Reward2",
                    "step_number": 1,
                    "items": [
                        {
                            "name": "Item0",
                            "count": 2
                        },
                        {
                            "name": "Item1",
                            "count": 5
                        }
                    ]
                },
                {
                    "id": 3,
                    "name": "Reward3",
                    "step_number": 2,
                    "items": [
                        {
                            "name": "Item0",
                            "count": 2
                        },
                        {
                            "name": "Item1",
                            "count": 5
                        }
                    ]
                },
                {
                    "id": 4,
                    "name": "Reward4",
                    "step_number": 3,
                    "items": [
                        {
                            "name": "Item0",
                            "count": 2
                        },
                        {
                            "name": "Item1",
                            "count": 5
                        }
                    ]
                }
            ]
        },
        "items": [
            {
                "name": "Item0",
                "count": 2
            },
            {
                "name": "Item1",
                "count": 5
            }
        ],
        "last_received_at": "2023-12-03T22:36:51.893480807+09:00"
    }
}
```
上記のようにマスターに設定されたアイテム(items[])を取得することができます。また2回目以降はリセット日時を超えないと受け取り出来ないようになります。

# まとめ
今回はソーシャルゲームのサーバーサイド開発の中で頻出するログインボーナスAPIについて解説してみました。ログインボーナスはゲームの特性や形式によって様々な仕様があります。この記事を参考にして各ゲームに合う仕様にカスタマイズして使用していただけると嬉しいです！

また、ゲームのサーバーサイドにはログインボーナス以外にも経験値付与やレベリングなど様々な要素があるため、今後も定期的に新しいAPIを追加していこうと思います。