48. 결투(듀얼) 신청 수락과 만료 타이머의 경합 (C#)

난이도 중 해설 보기 →

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

결함 코드 · C#
// [문제] 필드 결투(듀얼) 신청과 수락
//
// 시나리오
//   오픈 필드에서 플레이어끼리 1:1 결투를 신청할 수 있다.
//   A가 B에게 결투를 신청하면 B에게 30초짜리 신청서가 생기고,
//   B가 수락하면 두 사람이 결투 상태로 들어가며 결투 인스턴스가 생성된다.
//   30초 안에 수락하지 않으면 신청서는 만료 타이머에 의해 폐기된다.
//
// 요구사항
//   - 한 플레이어는 동시에 하나의 결투에만 참여할 수 있다.
//   - 전투 중이거나 이동(존 이동) 중인 플레이어는 결투를 시작할 수 없다.
//   - 여러 사람이 동시에 같은 사람에게 신청할 수 있고, 그중 하나만 성립해야 한다.
//   - 만료와 수락이 겹쳐도 두 플레이어의 결투 상태는 항상 짝이 맞아야 한다.
//
// 과제
//   (A), (B) 위치를 중심으로 이 코드가 동시 요청에서 어떤 결과를 낼 수 있는지 분석하고,
//   요구사항을 만족하도록 수정안을 제시하라.

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;

public enum PlayerState { Idle, InCombat, Zoning, InDuel }

public sealed class Player
{
    public long Id;
    public PlayerState State = PlayerState.Idle;
    public long CurrentDuelId;          // 0 = 없음
    public readonly object Sync = new object();
}

public sealed class DuelRequest
{
    public long Id;
    public long FromId;
    public long ToId;
    public Timer ExpireTimer;
    public bool Consumed;               // 수락되어 소비됨
}

public sealed class Duel
{
    public long Id;
    public long P1, P2;
    public DateTime StartedUtc;
}

public sealed class DuelService
{
    private readonly ConcurrentDictionary<long, Player> _players = new();
    private readonly ConcurrentDictionary<long, DuelRequest> _requests = new();
    private readonly ConcurrentDictionary<long, Duel> _duels = new();
    private long _nextRequestId;
    private long _nextDuelId;

    public DuelService(IEnumerable<Player> roster)
    {
        foreach (var p in roster) _players[p.Id] = p;
    }

    public long Request(long fromId, long toId)
    {
        if (fromId == toId) return 0;
        if (!_players.TryGetValue(fromId, out var from)) return 0;
        if (!_players.TryGetValue(toId, out var to)) return 0;

        if (from.State != PlayerState.Idle || to.State != PlayerState.Idle)
            return 0;

        var req = new DuelRequest
        {
            Id = Interlocked.Increment(ref _nextRequestId),
            FromId = fromId,
            ToId = toId,
        };
        _requests[req.Id] = req;
        req.ExpireTimer = new Timer(_ => Expire(req.Id), null, 30_000, Timeout.Infinite);
        return req.Id;
    }

    // 수락자(toId)의 세션 스레드에서 호출된다.
    public bool Accept(long requestId, long accepterId)
    {
        if (!_requests.TryGetValue(requestId, out var req)) return false;
        if (req.ToId != accepterId) return false;
        if (req.Consumed) return false;

        var a = _players[req.FromId];
        var b = _players[req.ToId];

        // (A)
        if (a.State != PlayerState.Idle || b.State != PlayerState.Idle)
            return false;

        var duel = new Duel
        {
            Id = Interlocked.Increment(ref _nextDuelId),
            P1 = a.Id,
            P2 = b.Id,
            StartedUtc = DateTime.UtcNow,
        };
        _duels[duel.Id] = duel;

        a.State = PlayerState.InDuel;
        a.CurrentDuelId = duel.Id;
        b.State = PlayerState.InDuel;
        b.CurrentDuelId = duel.Id;

        req.Consumed = true;
        req.ExpireTimer.Dispose();
        _requests.TryRemove(requestId, out _);
        return true;
    }

    // 타이머 스레드풀 스레드에서 호출된다.
    private void Expire(long requestId)
    {
        if (!_requests.TryGetValue(requestId, out var req)) return;

        // (B)
        if (!req.Consumed)
        {
            _requests.TryRemove(requestId, out _);
            var a = _players[req.FromId];
            var b = _players[req.ToId];
            a.State = PlayerState.Idle;
            a.CurrentDuelId = 0;
            b.State = PlayerState.Idle;
            b.CurrentDuelId = 0;
            req.ExpireTimer?.Dispose();
        }
    }

    public bool TryGetDuel(long duelId, out Duel duel) => _duels.TryGetValue(duelId, out duel);
}
내 리뷰 · C#
내 답안 · 자동 저장

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