콘텐츠로 이동

GameQuest 따라 읽기

가이드 홈 | 이전: ShoppingMall 따라 읽기 | 다음: ZoneWorld 따라 읽기

이 장을 읽고 나면

GameQuest 샘플을 편집기에 열고, client의 플레이 action이 player별 owner Spot에서 판정되어 진행과 완료가 push되기까지, 그리고 유실된 진행을 보정하기까지 코드를 따라갈 수 있다. 이 장의 코드는 언어별 예제 저장소의 GameQuest 샘플에서 가져온다.

샘플 고르기가 이 샘플이 무엇을 보여 주는지 소개했다. 이 장은 그 소개 다음에 읽는 자리다 — 역할과 코드 위치, 주요 시나리오의 메시지 흐름, 각 흐름에 등장하는 framework 기능과 그것을 설명하는 장을 소스가 놓인 순서대로 따라간다. 이 장은 GameQuest 샘플의 역할과 코드 위치, 주요 메시지 흐름, 실행 검증을 소스 순서대로 설명한다. 요구사항, 메시지 계약과 검증 기준은 GameQuest 시나리오에서 참고한다.

1. 이 샘플이 보여 주는 것

client의 action은 GameApi가 검증해 gameplay event로 만들고, 같은 PlayerId의 event는 QuestMission의 owner Spot 하나가 순서대로 판정한다. 판정 결과는 event stream에 기록되고, 진행과 완료는 player의 session Actor를 거쳐 연결로 push된다. ShoppingMall과 같은 owner Spot·event sourcing 구성이지만, 진행 tier의 메시지는 best-effort다 — 유실되면 authoritative fact를 읽어 보정한다.

↗ 크게 보기

이 장이 따라가는 흐름은 join과 session Actor bind → action 접수와 owner로의 send → owner Spot의 판정과 기록 → 진행 push → 보정과 종료 순서다.

2. 역할과 코드 위치

역할 process 수 소유하는 것 코드 위치
GameApi 2 STREAM session, session Actor, action 검증과 gameplay event, owner 호출 Server/GameApi
QuestMission 2 player quest Instance Spot, replay·판정·append·projection, notify Server/QuestMission
Client 1 join, action, reconnect, reconcile assertion Client

quest 조건, aggregate fold와 완료 규칙은 Server/QuestMission/Domain에 있고 framework type을 참조하지 않는다. action 검증과 event 생성은 Server/GameApi/Application이 맡는다. 메시지는 Shared의 JSON 계약이 정한다.

3. 서버 구성

GameApi는 Entry Spot과 session Actor factory를 object server로 등록하고 STREAM 연결을 받는다. QuestMission은 같은 mesh에 player quest Instance Spot factory를 등록한다.

Server/GameApi/Program.cs

var mesh = options
    .AddRouteMesh(SampleNames.MeshName)
    .Listen(topology.GameApiMeshEndpoint(apiName))
    .SetRoutingIdPrefix("game-api");
mesh.Objects()
    .Server()
    .AddEntrySpot<GameQuestEntrySpot>()
    .AddActorFactory<PlayerSessionActor, PlayerSessionActorFactory>(
        SampleNames.SessionActorType,
        factory => factory.RecreateOnRelocation()
    );
options
    .AddStreamNode(SampleNames.StreamNode)
    .Bind(configuration.StreamBindEndpoint)
    .EnableActorDispatch()
    .AddSession<GameQuestSession>();

QuestMission 쪽은 mesh의 object server로 Instance Spot factory만 등록한다.

Server/QuestMission/Program.cs

var mesh = options
    .AddRouteMesh(SampleNames.MeshName)
    .Listen(instance.MeshEndpoint)
    .SetRoutingIdPrefix("quest-mission");
mesh.Objects()
    .Server()
    .AddInstanceSpotFactory<PlayerQuestSpot>(
        SampleNames.PlayerQuestSpotType,
        factory => factory.RecreateOnRelocation()
    );

두 역할이 같은 mesh를 공유하고 mission별 channel 이름을 두지 않는다. Instance Spot factory는 relocation 때 target에서 다시 만드는 정책을 고른다 — 상태는 event stream에서 복원하면 된다. 종류별 생성 정책은 활성화와 수명이 다룬다.

4. join과 session Actor

client가 join하면 GameApi의 session handler가 PlayerId로 session Actor를 만들거나 찾아 현재 session에 묶는다. 이 Actor는 quest 상태를 갖지 않는다 — owner Spot이 보낸 notify를 연결로 옮기는 자리다.

Server/GameApi/Session/GameQuestSessionHandlers.cs

var actor = (
    await actors
        .GetOrCreate(request.PlayerId, SampleNames.SessionActorType)
        .Request(request)
        .Async(cancellationToken)
) switch
{
    ZLinkActorCreateResult.Existing value => value.Actor,
    ZLinkActorCreateResult.Created value => value.Actor,
    _ => throw new InvalidOperationException("Session Actor creation was rejected."),
};
_ = await context.Actors.BindOrGetAsync(actor, cancellationToken);

다른 GameApi에 재접속해도 같은 id의 Actor에 새 session이 묶인다. 묶기는 Session과 Actor 연결이 다룬다.

5. action 접수와 owner로의 send

action packet은 묶인 Actor로 relay되어 Entry Spot의 actor request handler에 도착한다. handler는 application service에 위임하고 발급된 EventId를 응답한다.

↗ 크게 보기

action 응답은 GameApi의 접수 완료만 뜻하며, owner Spot의 판정 결과는 이후 push로 확인한다.

Server/GameApi/Session/GameQuestSessionHandlers.cs

[ZLinkSpotActorRequestHandler(nameof(KillMonsterReq))]
internal sealed class KillMonsterHandler(GameplayActionService actions)
    : IZLinkEntrySpotActorRequestHandler<
        GameQuestEntrySpot,
        PlayerSessionActor,
        KillMonsterReq,
        KillMonsterRes
    >
{
    public async ValueTask<KillMonsterRes> HandleAsync(
        GameQuestEntrySpot entrySpot,
        PlayerSessionActor actor,
        IZLinkMessageContext context,
        KillMonsterReq request,
        CancellationToken cancellationToken
    )
    {
        actor.EnsurePlayer(request.PlayerId);
        return new KillMonsterRes(
            await actions.KillMonsterAsync(
                request.PlayerId,
                request.MonsterId,
                request.AreaId,
                request.IdempotencyKey,
                cancellationToken
            )
        );
    }
}

service는 idempotency key로 event를 저장한 뒤 owner에 보낸다. 같은 key의 재요청은 같은 EventId가 된다. owner가 Unavailable이면 그대로 실패한다 — framework가 다른 node에 다시 보내지 않는다.

Server/GameApi/Application/GameplayActionService.cs

var stored = await store.GetOrAddGameplayEventAsync(candidate, cancellationToken);
string routedTo;
try
{
    routedTo = await ownerDispatcher.DispatchAsync(stored, cancellationToken);
}
catch (ZLinkFrameworkException exception)
    when (exception.Kind == ZLinkFrameworkErrorKind.Unavailable)
{
    logger.LogInformation("gamequest-owner unavailable player={PlayerId}", stored.PlayerId);
    throw;
}

owner로 가는 메시지는 PlayerId를 Spot id로 하는 one-way send다. 그 id의 Spot이 없으면 이 첫 메시지가 eligible node 중 한 곳에 Spot을 만든다.

Server/GameApi/Infrastructure/ZLink/GameplayEventOwnerDispatcher.cs

await spots
    .SendToSpot(
        gameplayEvent.PlayerId,
        new GameplayMsg(
            gameplayEvent.EventId,
            gameplayEvent.PlayerId,
            gameplayEvent.EventType,
            JsonSerializer.SerializeToElement(
                new GameplayPayload(
                    gameplayEvent.Value,
                    gameplayEvent.Count,
                    gameplayEvent.SourceApi
                )
            ),
            gameplayEvent.CreatedAtUnixMs
        )
    )
    // A missing player owner is activated by this same gameplay
    // message; the caller never resolves or chooses a physical node.
    .InstanceSpot(SampleNames.PlayerQuestSpotType)
    .InMesh(SampleNames.MeshName)
    .Async();

응답은 GameApi가 접수했다는 뜻이지 owner가 판정했다는 뜻이 아니다. 판정 결과는 뒤에 오는 push로 확인한다. Instance Spot 호출은 Spot이, send와 request의 차이는 Channel 메시징이 다룬다.

6. owner Spot의 판정과 기록

Spot이 만들어지면 initialize callback이 PlayerId와 generation을 기록한다.

Server/QuestMission/Infrastructure/ZLink/Spots/PlayerQuestSpot/PlayerQuestSpot.cs

public async ValueTask OnInitializeAsync(CancellationToken cancellationToken)
{
    PlayerId = Context.SpotId;
    RehydrationCount = await store.RecordOwnerRehydratedAsync(PlayerId, cancellationToken);
    ReplayEvidencePending = RehydrationCount > 1;
    logger.LogInformation(
        "gamequest-owner ready player={PlayerId} rehydrationCount={RehydrationCount} node={NodeId} objectGeneration={ObjectGeneration}",
        PlayerId,
        RehydrationCount,
        processor.MissionName,
        Context.ObjectGeneration
    );
}

gameplay 메시지는 Spot의 packet handler에 도착한다.

Server/QuestMission/Infrastructure/ZLink/Spots/PlayerQuestSpot/PlayerQuestSpot.cs

internal sealed class ApplyGameplayEventHandler
    : IZLinkSpotPacketHandler<PlayerQuestSpot, GameplayMsg>
{
    public ValueTask HandleAsync(
        PlayerQuestSpot spot,
        GameplayMsg message,
        CancellationToken cancellationToken
    )
    {
        return spot.ApplyGameplayEventAsync(message, cancellationToken);
    }
}

processor는 (PlayerId, QuestId) stream을 읽어 aggregate를 복원하고, 조건을 판정해 event를 append한 뒤 projection을 갱신한다. 이미 저장된 source event는 다시 append되지 않는다.

Server/QuestMission/Application/QuestEventProcessor.cs

var stream = await store.ReadQuestStreamAsync(
    gameplayFact.PlayerId,
    definition.QuestId,
    cancellationToken
);
var aggregate = QuestProgressAggregate.Rehydrate(definition, stream);
if (replayRehydrationCount is { } rehydrationCount)
    logger.LogInformation(
        "gamequest-mission replayed player={PlayerId} rehydrationCount={RehydrationCount} objectGeneration={ObjectGeneration}",
        gameplayFact.PlayerId,
        rehydrationCount,
        objectGeneration
    );
var decision = aggregate.Decide(gameplayFact);
if (decision is null)
    return;
if (!await store.AppendAndProjectAsync(decision.State, decision.Events, cancellationToken))
    return;

같은 player의 메시지는 한 Spot의 turn 안에서 한 번에 하나씩 처리되므로 fold와 append 사이에 경쟁하는 writer가 없다. 직렬 실행의 범위는 실행 모델이 다룬다.

7. 진행 push — owner에서 session Actor로

append가 끝나면 QuestMission은 진행과 완료 메시지를 PlayerId를 가리키는 Actor direct send로 보낸다. 어느 GameApi에 그 Actor가 있는지는 framework가 찾는다.

Server/QuestMission/Infrastructure/ZLink/GameApiQuestClients.cs

var contracts = projection.Select(QuestContractMapper.ToContract).ToArray();
foreach (var progress in contracts)
    await actors
        .SendToActor(playerId, new QuestProgressMsg(playerId, progress))
        .Async();

if (!string.IsNullOrWhiteSpace(completedQuestId))
{
    var completed = contracts.First(progress => progress.QuestId == completedQuestId);
    await actors
        .SendToActor(playerId, new QuestCompletedMsg(playerId, completed, true))
        .Async();
}

return new QuestProgressNotifyResult(true, null, null);

GameApi의 Entry Spot handler가 그것을 받아 Actor의 bound session으로 notify를 push한다.

Server/GameApi/Session/PlayerSessionActor.cs

[ZLinkSpotActorSendHandler(nameof(QuestProgressMsg))]
internal sealed class QuestProgressMsgActorHandler
    : IZLinkEntrySpotActorSendHandler<GameQuestEntrySpot, PlayerSessionActor, QuestProgressMsg>
{
    public async ValueTask HandleAsync(
        GameQuestEntrySpot entrySpot,
        PlayerSessionActor actor,
        IZLinkMessageContext context,
        QuestProgressMsg message,
        CancellationToken cancellationToken
    )
    {
        await actor
            .Context.BoundSession.Send(new QuestProgressNotify(message.PlayerId, message.Progress))
            .Async();
    }
}

session이 묶여 있지 않은 동안의 notify는 성공 조건이 아니다. 상태는 event store에 있고, 재접속한 client는 조회로 복원한다. id로 Actor를 호출하는 경로는 Actor가 다룬다.

8. 보정과 종료

진행 메시지가 유실되어 fact와 fold가 어긋나면 client나 운영 trigger가 sync 요청을 보낸다. owner Spot은 authoritative snapshot을 읽어 같은 판정 경로로 보정 event를 append한다.

↗ 크게 보기

유실된 진행은 authoritative snapshot을 같은 판정 경로에 다시 넣어 보정 event로 기록한다.

Server/QuestMission/Application/QuestEventProcessor.cs

var snapshot = await snapshots.ReadSnapshotAsync(playerId, cancellationToken);
if (snapshot.KillCount > 0)
    await ProcessAsync(
        new GameplayFact(
            $"{playerId}-snapshot-{snapshot.Version}",
            playerId,
            "SnapshotKillCount",
            "kills",
            snapshot.KillCount,
            "api-a",
            DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
        ),
        replayRehydrationCount,
        objectGeneration,
        cancellationToken
    );

return await store.ReadProjectionAsync(playerId, cancellationToken);

명시적 close 뒤에 같은 PlayerId로 온 메시지는 새 generation의 Spot을 만들고, 그 Spot은 stream을 replay해 이어간다.

Server/QuestMission/Infrastructure/ZLink/Spots/PlayerQuestSpot/PlayerQuestSpot.cs

internal sealed class ClosePlayerQuestHandler
    : IZLinkSpotPacketHandler<PlayerQuestSpot, ClosePlayerQuestMsg>
{
    public ValueTask HandleAsync(
        PlayerQuestSpot spot,
        ClosePlayerQuestMsg message,
        CancellationToken cancellationToken
    )
    {
        _ = message;
        _ = spot.Context.CloseAsync(cancellationToken);
        return ValueTask.CompletedTask;
    }
}

Ready owner의 process가 사라지면 진행 중인 요청은 Unavailable로 끝나고 framework는 다른 node에 같은 Spot을 자동으로 만들지 않는다. Close와 generation은 활성화와 수명이 다룬다.

9. 실행과 검증

runner 하나가 Redis 컨테이너, 서버 process와 client 시나리오를 함께 띄운다.

framework/languages/dotnet/samples/GameQuest/run_sample.sh

client는 action 응답, 진행과 완료 notify, 재접속 뒤의 조회, 보정 결과를 assertion으로 확인한다. 확인 항목과 로그 문자열은 GameQuest 시나리오가 정한다.

10. 관련 문서