5. 확률 아이템(가챠) 뽑기 + 천장 시스템

난이도 최상 해설 보기 →

결함을 모두 찾고 원인·수정안·더 나은 설계를 제시하라. 마커 (A)(B) 는 주목 위치 힌트다.

결함 코드 · C#
// ============================================================================
// [코드리뷰 문제] C# - 확률 아이템(가챠) 뽑기 + 천장(pity) 시스템
// ----------------------------------------------------------------------------
// 시나리오:
//   - 플레이어가 유료 재화로 가챠를 돌린다. 등급별 확률표가 있다.
//   - "천장(pity)": 90회 연속 최고등급이 안 나오면 90회째 최고등급 확정.
//   - 클라이언트는 연출(애니메이션)을 위해 결과를 미리 알아야 부드럽다.
//   - 결과 보상은 인벤토리에 지급되고, 뽑기 기록은 영수증으로 남는다.
//
// 요구사항:
//   - 가챠 결과는 서버가 권위적으로 결정/검증해야 한다(치팅 방지).
//   - 천장 카운터는 정확히 관리되어야 한다.
//   - 같은 플레이어가 여러 기기/탭에서 동시에 뽑아도 정합성이 유지돼야 한다.
//   - 재화 차감과 보상 지급은 원자적이어야 한다(돈만 빠지고 보상 없음 금지).
//   - 클라이언트 재전송으로 인한 중복 뽑기는 한 번만 반영.
//
// 과제:
//   이 코드의 잠재적 문제를 모두 찾고, 왜 문제인지 설명하고,
//   수정안과 더 나은 설계를 제시하라.
//   (먼저 직접 리뷰를 적은 뒤 answer.md 와 대조할 것)
// ============================================================================

using System;
using System.Collections.Generic;

public class GachaRequest
{
    public long PlayerId;
    public string BannerId;
    public int    Count;        // 몇 연차
    public int[]  ClientRolls;  // (A) 클라가 미리 굴린 0~9999 난수들(연출 동기화용)
    public long   Seed;         // (B) 클라가 보낸 시드
}

public class GachaItem { public int ItemId; public int Grade; public int Weight; }

public class GachaService
{
    private readonly Dictionary<string, List<GachaItem>> _banners;
    private readonly Dictionary<long, int> _pityCounter = new Dictionary<long, int>(); // (C)
    private readonly Random _rng = new Random();                                       // (D)
    private const int PITY = 90;
    private const int MAX_GRADE = 5;

    public GachaService(Dictionary<string, List<GachaItem>> banners) { _banners = banners; }

    public List<GachaItem> Pull(GachaRequest req, Player player)
    {
        var pool = _banners[req.BannerId];
        int totalWeight = 0;
        foreach (var it in pool) totalWeight += it.Weight;

        var results = new List<GachaItem>();

        // 재화 차감 (1연차당 100)
        long cost = req.Count * 100;
        lock (player.Lock)
        {
            if (player.Gold < cost) throw new Exception("not enough");
            player.Gold -= cost;                       // (E)
        }

        for (int i = 0; i < req.Count; i++)
        {
            // (F) 클라가 보낸 난수를 사용해 결과 결정(연출과 일치시키려고)
            int roll = req.ClientRolls[i];

            // 천장: 카운터가 PITY-1 이상이면 최고등급 확정
            int pity = _pityCounter.TryGetValue(req.PlayerId, out var c) ? c : 0;  // (G)

            GachaItem picked;
            if (pity >= PITY - 1)
            {
                picked = PickHighest(pool);
                _pityCounter[req.PlayerId] = 0;        // (H)
            }
            else
            {
                picked = PickByWeight(pool, totalWeight, roll);
                if (picked.Grade == MAX_GRADE)
                    _pityCounter[req.PlayerId] = 0;
                else
                    _pityCounter[req.PlayerId] = pity + 1;   // (I)
            }

            results.Add(picked);
            // (J) 보상 지급 (인벤토리에 추가하는 코드는 생략, 여기서 직접)
            GrantReward(player, picked);
        }

        return results;
    }

    private GachaItem PickByWeight(List<GachaItem> pool, int totalWeight, int roll)
    {
        // roll: 0..9999 를 가중치 구간에 매핑
        int point = roll % totalWeight;
        int acc = 0;
        foreach (var it in pool)
        {
            acc += it.Weight;
            if (point < acc) return it;
        }
        return pool[pool.Count - 1];
    }

    private GachaItem PickHighest(List<GachaItem> pool)
    {
        GachaItem best = pool[0];
        foreach (var it in pool) if (it.Grade > best.Grade) best = it;
        return best;
    }

    private void GrantReward(Player player, GachaItem item) { /* 인벤토리 지급 */ }
}

public class Player
{
    public long Id;
    public long Gold;
    public readonly object Lock = new object();
}
내 리뷰 · C#
내 답안 · 자동 저장

작성 후 위 해설 보기에서 모범 해설과 대조하세요.