> For the complete documentation index, see [llms.txt](/ko/llms.txt)


# C# 스테이트 머신 정의 레퍼런스

C#으로 기술하는 상태, 조작, 처리의 계속, 보상, 난수의 사양



C# 클래스로 게임이 보유하는 데이터와 플레이어의 조작에 대한 처리를 정의합니다. 이 페이지는 그 서식과 API의 레퍼런스입니다.

정의 생성에는 **LocalStateMachineKit**를 사용합니다. C# 정의로부터 GS2-StateMachine에 등록할 GSL을 생성합니다. 투기적 실행을 사용하는 경우에는 Unity에서 조작을 호출하기 위한 C# 클래스도 생성합니다.

- 실제로 만들어 동작시키는 절차: [튜토리얼]()
- 생성한 C# 클래스를 게임에서 사용하는 방법: [StateMachine의 투기적 실행 Session API]()
- 출력 위치나 SDK 참조 지정: [생성 설정·등록]()

생성하는 GSL의 실행 대상은 생성 설정의 [`hostProfile`](#project)에서 선택합니다. 이 페이지는 기본값인 `LocalReplayV1`을 전제로 설명합니다.

| `hostProfile` | 실행 방식 | 이 페이지와의 차이 |
| --- | --- | --- |
| `LocalReplayV1`(기본값) | StateMachine의 투기적 실행. 클라이언트에서 조작을 실행하고, 서버가 조작 이력을 재생하여 검증한다 | 없음 |
| `RealtimeRoomV1` | Realtime 룸 안에서 실행한다 | 클래스·record·Start·Step의 작성 방법은 같다. [GameRewards](#rewards)와 [GameRandom](#random) 대신 Room API를 사용한다 |

`RealtimeRoomV1`의 입력, Room API, 동기화 항목과 조작 권한, 룸 안에서의 실행 규칙은 [룸 정의 레퍼런스]()를 참조해 주세요.

## 정의의 구성 요소 {#definition}

하나의 정의는 다음 요소로 구성합니다.

| 요소 | 의미 | 예 |
| --- | --- | --- |
| 상태 | 다음 조작으로 인계하는 게임 데이터 | 소지 수, HP, 선택한 루트 |
| 조작 | 외부로부터 받아들이는 입력과 그 처리를 기술한 메서드 | 아이템을 사용한다, 루트를 선택한다 |
| 결과 | 게임 종료 시에 남기는 데이터 | 최종 스코어, 클리어 여부 |
| Step | 처리 후의 상태와 다음에 무엇을 할지를 반환하는 값 | 조작을 기다린다, 처리를 계속한다, 종료한다 |

여기서 말하는 「상태」는 저장하는 데이터입니다. 「현재 어떤 조작을 받아들일지」는 별도로 `Step`에서 지정합니다.

`Step<상태 타입, 결과 타입, 입력을 거부하는 이유의 타입>`의 세 타입을 정의 전체에서 통일합니다. 다음 예는 `using Step = ...`으로 이 긴 타입명을 `Step`으로 약기하고 있습니다.

### 정의 전체 예

수치 `Count`를 보유하는 `Counter`의 정의입니다. `Add`로 지정한 수를 더하고, `Finish`로 종료합니다. `Add`와 `Finish`는 이 예에서 정의한 조작 이름입니다.

```csharp
using Gs2.LocalStateMachine.Authoring;
using Step = Gs2.LocalStateMachine.Authoring.Step<
    GameRules.CounterState, GameRules.CounterResult, GameRules.CounterError>;

namespace GameRules;

public record CounterState(int Count);
public record CounterResult(int Count);
public enum CounterError { InvalidAmount = 1001 }

public static class Counter
{
    public static Step Start() =>
        Step.WaitFor(nameof(Add), new CounterState(0)).Or(nameof(Finish));

    public static Step Add(CounterState state, int amount)
    {
        if (amount < 1 || amount > 10)
            return Step.Ignore(CounterError.InvalidAmount);

        var next = state with { Count = state.Count + amount };
        return Step.Stay(next);
    }

    public static Step Finish(CounterState state) =>
        Step.End(new CounterResult(state.Count), state);
}
```

`Start`는 시작 시에 한 번 실행합니다. 이 예에서는 Count를 0으로 하고 Add와 Finish를 받아들입니다. Add 처리 후에도 같은 두 조작을 받아들이며, Finish 처리 후에는 종료합니다.

## 선언 규칙 {#declarations}

### 룰 클래스

```csharp
public static class Counter
{
    // Start, 조작, 자동 처리, 보조 함수를 선언한다
}
```

클래스 이름이 스테이트 머신 이름이 됩니다. 하나의 소스 폴더에 룰 클래스를 하나 둡니다. 상태·결과의 record와 enum은 같은 폴더 안에서 파일을 나누어도 상관없습니다.

클래스 안에 선언할 수 있는 것은 static 메서드입니다. 필드, 프로퍼티, 생성자, 타입 파라미터는 사용할 수 없습니다.

### 메서드

다음의 `Step`은 같은 상태·결과·이유 타입을 지정한 `Step<TState, TResult, TCode>`를 가리킵니다.

| 종류 | 선언 형태 | 호출되는 시점 |
| --- | --- | --- |
| 시작 처리 | `public static Step Start()` | 게임 시작 시. 필수 |
| 조작 | `public static Step Add(TState state, int amount)` | 외부로부터 Add 입력을 받았을 때 |
| 자동 처리 | `private static Step Resolve(TState state)` | `Step.Next`로 지정되었을 때 |
| 보조 함수 | `private static int Calculate(int value)` | 정의 안의 메서드에서 일반 C# 함수로 호출했을 때 |

조작과 자동 처리의 차이는 외부로부터의 입력을 기다리는지 여부입니다. 자동 처리는 입력을 기다리지 않고 실행합니다. `Step`을 반환하는 메서드로 진행하는 경우에는 `WaitFor` 또는 `Next`를 사용하며, `Add(...)`처럼 직접 호출하지 않습니다.

메서드는 동기적인 static 메서드로 합니다. 다음 선언은 사용할 수 없습니다.

- 같은 이름의 메서드 오버로드
- async, 제네릭 메서드
- ref / out / in 인수, params, 생략 가능한 인수

타입·메서드·인수 등의 이름에는 ASCII 영문자 또는 `_`로 시작하는 영숫자와 `_`를 사용합니다. 생성 코드의 예약어나 이름 충돌은 생성 시에 검사합니다.

### 인수를 전달하는 측

`Start`는 인수를 받지 않습니다. 그 외의 `Step`을 반환하는 메서드에서는 첫 번째 인수를 상태 타입으로 합니다.

```csharp
public static Step Add(CounterState state, int amount, GameRewards rewards)
```

이 선언에서 호출자가 전달하는 것은 `amount`뿐입니다. 현재의 `state`와 보상용 `rewards`는 실행 환경이 전달합니다. 생성되는 Unity용 클래스에서는 `game.Add(3)`으로 호출합니다.

| 인수 | 값을 전달하는 측 | 사용할 수 있는 곳 |
| --- | --- | --- |
| 첫 번째 인수의 상태 | 실행 환경 | 조작·자동 처리에서 필수 |
| `GameRewards` | 실행 환경 | 조작·자동 처리. 각각 최대 하나 |
| `GameRandom<TStream>` | 실행 환경 | 조작·자동 처리. 각각 최대 하나 |
| 위 이외의 데이터 | 조작의 호출자 | public 조작만 |

자동 처리에 새로운 외부 입력을 전달할 수는 없습니다. 조작에서 자동 처리로 값을 인계하는 경우에는 상태에 포함합니다. 입력 이름 `revision`은 예약되어 있으므로 사용할 수 없습니다.

초기화에서 난수나 보상을 사용하는 경우에는 Start에서 초기 상태를 만들고, `Next`로 필요한 인수를 가진 자동 처리로 진행합니다.

## 데이터 타입 {#types}

### 상태·결과·조작 입력

| 타입 | 취급 |
| --- | --- |
| `int` | 부호 있는 32비트 정수 |
| `long` | 부호 있는 64비트 정수. int32나 부동소수점으로 좁히지 않고 다룬다 |
| `float` / `double` | 각각의 부동소수점 정밀도로 다룬다. NaN·Infinity는 저장·전송할 수 없다 |
| `bool` | 진릿값 |
| `string` | null이 아닌 문자열 |
| enum | 소스 안에서 선언한, 기반 타입이 `int`인 열거형 |
| record | 위 타입을 프로퍼티로 가지는 데이터 타입. 아래의 선언 형식을 사용한다 |

상태와 결과는 다음과 같이 괄호 안의 인수로 프로퍼티를 선언하는 record로 합니다. 이 선언 형식을 positional record라고 부릅니다. 조작 입력에는 표의 기본 타입·enum·record를 지정할 수 있습니다.

```csharp
public record CounterState(int Count);
public record CounterResult(int Count);
public record MoveInput(int X, int Y);
```

갱신 후의 상태는 `new` 또는 `with`로 만들어 Step에 전달합니다.

```csharp
var next = state with { Count = state.Count + amount };
return Step.Stay(next);
```

record의 프로퍼티에 다른 record, 배열, 컬렉션, null을 포함할 수는 없습니다. 또한 상속, 타입 파라미터, 추가 멤버, 프로퍼티의 기본값, `record struct`는 대상 외입니다.

### enum의 종류 {#enum-kinds}

| 용도 | 선언 예 | 수치 규칙 |
| --- | --- | --- |
| 상태·입력 등의 일반 enum | `enum Choice { None, Small, Large }` | 수치를 명시적으로 지정하지 않는다 |
| `TCode`: Ignore의 이유 | `enum Error { InvalidAmount = 1001 }` | 양의 int32 값을 각 멤버에 명시. 중복 불가 |
| `TStream`: 난수의 용도 | `enum RandomSource { Drop = 1 }` | 0 이상의 int32 값을 각 멤버에 명시. 중복 불가 |

오류 코드와 난수 스트림 ID는 운용 시작 후에도 의미를 바꾸지 않는 값으로 관리합니다. 하나의 룰 클래스에서 사용하는 난수용 enum은 한 종류입니다.

### 보상 리퀘스트 안의 SDK 데이터

보상 조립에 사용하는 SDK 오브젝트는 위의 상태·조작 입력과는 별도로 취급합니다. SDK가 선언하는 nullable, 배열, 중첩 Model을 사용할 수 있습니다. SDK 오브젝트를 `TState`나 조작 입력에 저장할 수는 없습니다. 자세한 내용은 [GameRewards](#rewards)를 참조해 주세요.

## 지원하는 C# 구문 {#language}

정의 안에서 사용할 수 있는 C# 구문은 다음과 같습니다.

| 분류 | 지원 범위 |
| --- | --- |
| 선언·변수 | 지원 타입의 로컬 변수, 대입, 상수 |
| 데이터 생성 | positional record의 `new`와 `with`, SDK 리퀘스트·Model·배열의 생성 |
| 분기 | if, 조건식, 상수·when·마지막 `_`를 사용하는 switch 식 |
| 반복 | for, while, break, continue |
| 함수 | 같은 클래스 안의 private 보조 함수, 이름 있는 인수, return |
| 논리 연산 | bool의 부정·비교, `&&` / `||`의 단락 평가 |
| 수치 연산 | checked int32 / int64의 사칙연산·나머지, float / double 연산, 지원하는 수치 변환, 로컬 변수에 대한 복합 대입 |
| 수치 함수 | int32의 `Math.Min` / `Math.Max` |
| SDK의 nullable 값 | `HasValue`, `Value`, `GetValueOrDefault`, `??` |

일반적인 컬렉션, foreach, 중첩 record, async, 임의의 외부 API 호출은 대상 외입니다. 정수 연산은 오버플로를 검사하는 설정으로 생성합니다. 루프와 함수 호출에는 실행량의 상한이 있습니다.

## 1회 조작의 처리 범위 {#execution}

외부로부터 하나의 조작을 받으면 그 메서드를 실행합니다. `Next`가 반환된 경우에는 지정한 자동 처리도 이어서 실행합니다. 이 일련의 처리를 1회의 입력으로 취급합니다.

- `WaitFor` 또는 `Stay`에 도달하면 갱신 후의 상태로 다음 입력을 기다립니다.
- `End`에 도달하면 최종 상태와 결과를 보유하고 종료합니다.
- 도중에 `Ignore`에 도달하면 이번 상태 변경·난수 진행·보상 요청을 취소하고, 입력 전과 같은 상태로 기다립니다. 조작 이력에도 추가하지 않습니다.

여기서의 처리 완료는 로컬 실행의 결과입니다. 서버의 검증은 별도로 [Session의 Confirm](#confirmation)으로 조작 이력을 전송하여 받습니다.

## Step API {#step}

Step은 다음 동작을 지정하는 반환값입니다. 다음의 `TState`는 상태 타입, `TResult`는 결과 타입, `TCode`는 입력을 거부하는 이유의 enum을 가리킵니다.

| API | 다음 동작 | 보유하는 상태 |
| --- | --- | --- |
| `WaitFor` | 지정한 조작을 기다린다 | 인수의 상태 |
| `Stay` | 입력 전과 같은 조작의 조합을 기다린다 | 인수의 상태 |
| `Next` | 지정한 자동 처리를 즉시 실행한다 | 인수의 상태를 후속 처리에 전달한다 |
| `End` | 종료한다 | 인수의 상태. 생략한 경우에는 현재 상태 |
| `Ignore` | 이번 입력을 취소하고 다시 기다린다 | 입력 전의 상태 |

`WaitFor`, `Or`, `Next`의 처리 대상은 같은 룰 클래스의 메서드를 가리키는 `nameof(...)`로 지정합니다. 문자열 리터럴이나 변수, Start는 지정할 수 없습니다.

### WaitFor / Or

다음에 받아들일 조작을 지정합니다.

```csharp
static Step WaitFor(string target, TState state);
Step Or(string target);
```

`state`를 다음 상태로 하고, `target`으로 지정한 public 조작의 입력을 기다립니다. 여러 조작을 받아들이는 경우에는 `WaitFor(...).Or(...).Or(...)`로 연결합니다.

```csharp
return Step.WaitFor(nameof(Add), nextState).Or(nameof(Finish));
```

`Or`는 WaitFor 호출에 연결합니다. 같은 조작을 중복 지정할 수 없습니다.

### Stay

상태를 갱신하고, 같은 조작을 계속 받아들입니다.

```csharp
static Step Stay(TState state);
```

`state`를 다음 상태로 하고, **이번 입력을 받아들이기 직전과 같은 조작의 조합**으로 대기합니다. 실행 중인 함수를 한 번 더 호출한다는 의미가 아닙니다.

```csharp
return Step.Stay(nextState);
```

예를 들어 Add와 Finish를 받아들이고 있었던 경우, Add에서 Stay를 반환하면 양쪽을 계속 받아들입니다. 첫 입력 대기에 도달하기 전의 `Start`와 그 자동 처리에서는 사용할 수 없습니다.

### Next

입력을 기다리지 않고 다음 처리를 실행합니다.

```csharp
static Step Next(string target, TState state);
```

`state`를 전달하여 `target`으로 지정한 private 자동 처리를 즉시 실행합니다.

```csharp
return Step.Next(nameof(Resolve), nextState);
```

호출 대상의 선언 예는 `private static Step Resolve(CounterState state)`입니다. 필요하다면 난수·보상 인수도 지정할 수 있습니다. public 조작, `Start`, 새로운 플레이어 입력을 필요로 하는 메서드는 대상으로 할 수 없습니다.

처리의 채택·취소는 [1회 조작의 처리 범위](#execution)를 따릅니다. `Next`의 순환은 생성 시에 거부합니다. 입력을 반복해서 기다리는 경우에는 `WaitFor` 또는 `Stay`를 사용합니다.

### End

결과를 남기고 게임을 종료합니다.

```csharp
static Step End(TResult result);
static Step End(TResult result, TState state);
```

결과를 보유하고 스테이트 머신을 종료합니다. 종료 후에는 새로운 조작을 받아들이지 않습니다.

| 인수 | 동작 |
| --- | --- |
| `result`만 | 그 시점의 상태를 보유하고 결과를 설정한다 |
| `result, state` | 지정한 상태를 최종 상태로 하고 결과를 설정한다 |

```csharp
return Step.End(new CounterResult(nextState.Count), nextState);
```

로컬 변수 `nextState`를 만든 것만으로는 보유되는 상태가 갱신되지 않습니다. 갱신 후의 상태를 남기는 경우에는 두 번째 인수로 전달합니다. `Start`에서 즉시 종료하는 경우에도 초기 상태를 두 번째 인수로 지정합니다.

종료와 보상 요청은 독립적입니다. 보상을 요청하기 위해 End를 반환할 필요는 없습니다.

### Ignore

게임 룰상 받아들일 수 없는 입력을 이유와 함께 취소합니다.

```csharp
static Step Ignore(TCode code);
```

이번 입력을 채택하지 않고 이유를 반환합니다. `code`에는 오류 enum의 멤버를 직접 지정합니다. 변수로 지정할 수는 없습니다.

```csharp
return Step.Ignore(CounterError.InvalidAmount);
```

취소하는 범위는 [1회 조작의 처리 범위](#execution)를 따릅니다. `Start`와 첫 입력 대기 이전의 자동 처리에서는 사용할 수 없습니다.

## GameRewards: 보상 요청 {#rewards}

게임 안에서 정해진 보상을 GS2 SDK의 입수 리퀘스트로 지정합니다. 조작 또는 자동 처리의 인수에 `GameRewards`를 추가합니다. 게임 도중에도 사용할 수 있습니다.

### Grant(request)

```csharp
void Grant<TRequest>(TRequest request) where TRequest : class;
```

`request`에 전달할 수 있는 것은 SDK가 `AcquireAction`에 등록하고 있는 입수 리퀘스트입니다. 참조 API나 소비 API의 리퀘스트는 생성 시에 거부합니다.

입수 조작은 각 서비스 트랜잭션 액션의 **Acquire Action**에서 찾을 수 있습니다. 예를 들어 [Inventory의 입수 액션](#acquire-action)에서 입수 내용을 확인하고, 대응하는 SDK의 Request 타입을 사용합니다. 사용 중인 SDK가 그 타입에 대응하는지는 생성 시에 검사합니다.

다음 예에서는 Add로 Count를 늘릴 때마다 같은 수의 parts를 요청합니다. using은 정의 파일의 선두, Add는 룰 클래스 안에 기술합니다.

```csharp
using Gs2.Gs2Inventory.Request;
using Gs2.Gs2Inventory.Model;

// Counter 클래스 안의 조작
public static Step Add(CounterState state, int amount, GameRewards rewards)
{
    if (amount < 1 || amount > 10)
        return Step.Ignore(CounterError.InvalidAmount);

    var next = state with { Count = state.Count + amount };
    rewards.Grant(new AcquireSimpleItemsByUserIdRequest()
        .WithNamespaceName("rewards")
        .WithInventoryName("bag")
        .WithAcquireCounts(new[] {
            new AcquireCount().WithItemName("parts").WithCount(amount)
        }));
    return Step.Stay(next);
}
```

`rewards`, `bag`, `parts`는 입수 대상의 리소스 이름입니다. 실제 서비스에서 사용하는 경우에는 등록된 이름을 지정합니다. 호출자는 계속 `game.Add(3)`으로 호출하며, GameRewards는 실행 환경이 전달합니다.

### 지정할 수 있는 값

SDK의 리퀘스트 타입과 Model 타입을 그대로 사용합니다.

| SDK의 값 | 취급 |
| --- | --- |
| `int?` / `long?` | 각각의 정수 범위를 유지한다 |
| `float?` / `double?` | 각각의 정밀도를 유지한다. NaN·Infinity는 전송 불가 |
| bool·문자열·null | SDK의 선언에 따라 지정한다 |
| 배열·중첩 Model | SDK의 타입으로 조립한다 |

`new`, 오브젝트 이니셜라이저, `With`, 프로퍼티 읽기·쓰기, 배열 생성·인덱스 접근을 사용할 수 있습니다. 변수나 private 보조 함수를 사용하여 조립할 수도 있습니다.

Grant는 호출한 시점의 내용을 저장합니다. 그 후에 원래 오브젝트를 변경해도 요청한 보상은 바뀌지 않습니다. 수취인, `TimeOffsetToken`, `DuplicationAvoider`는 실행 환경이 설정하므로 정의에서는 지정하지 않습니다. 0·음수·null의 허용 여부 등은 입수 대상 API의 사양을 따릅니다.

SDK 타입을 사용하는 경우의 참조 설정은 [SDK DLL 참조](#sdk-references)를 참조해 주세요. `bindings.json`은 필요하지 않습니다.

### 요청과 지급의 차이

Grant는 보상을 요청하는 처리입니다. 로컬 인벤토리를 그 자리에서 늘리는 처리가 아닙니다. Ignore가 된 입력의 요청은 취소합니다.

실제 지급은 조작 이력을 서버에 전송하고 검증을 받은 후에 이루어집니다. **로컬에서의 입력 수리, 서버 검증, 보상 반영은 별개의 단계입니다.** 확인 방법은 [Session의 Confirm](#confirmation)을 참조해 주세요.

### Grant(id, amount): enum에 의한 지정

```csharp
void Grant<T>(T id, int amount) where T : Enum;
```

Inventory의 SimpleItem에 한해 보상 enum과 개수로도 지정할 수 있습니다. 예를 들어 `enum Reward { Parts }`를 정의하고 `rewards.Grant(Reward.Parts, amount)`로 호출합니다. 입수 대상은 별도로 [bindings.json](#bindings)에 설정합니다.

이 형식의 amount는 int32입니다. 0이면 요청을 발행하지 않으며, 음수는 실행 오류입니다. SDK 리퀘스트를 전달하는 형식의 수치 범위·제약과는 다릅니다.

## GameRandom: 난수 {#random}

서버가 같은 결과를 재현할 수 있는 난수를 생성합니다. 조작 또는 자동 처리의 인수에 `GameRandom<TStream>`을 추가합니다. `TStream`은 난수의 용도를 나타내는 enum입니다.

```csharp
RandomStream Stream(TStream stream);
int RangeInclusive(int min, int max);
```

`Stream`으로 용도를 선택하고, `RangeInclusive`로 양 끝을 포함하는 범위의 int32를 얻습니다. 예를 들어 `RangeInclusive(2, 4)`의 반환값은 2·3·4입니다. `min > max`는 실행 오류입니다.

다음 예에서는 Add 1회당 Count를 2~4 늘립니다. enum은 룰 클래스 바깥, Add는 클래스 안에 기술합니다.

```csharp
public enum RandomSource { Count = 1 }

// Counter 클래스 안의 조작
public static Step Add(CounterState state, GameRandom<RandomSource> random)
{
    var amount = random.Stream(RandomSource.Count).RangeInclusive(2, 4);
    var next = state with { Count = state.Count + amount };
    return Step.Stay(next);
}
```

이 경우 호출자는 `game.Add()`로 호출합니다. 난수 인수는 전달하지 않습니다. 용도별로 enum 멤버를 나누고, ID는 운용 시작 후에도 의미를 바꾸지 않는 값으로 합니다. 수치 규칙은 [enum의 종류](#enum-kinds)를 참조해 주세요.

`System.Random`이나 `UnityEngine.Random`은 사용할 수 없습니다. 생성·재생에는 실행 환경의 시드와 사용 상황을 사용합니다. Ignore의 경우에는 [공통 취소 규칙](#execution)을 따릅니다.

## 검증과 실패 시의 동작 {#validation}

| 상황 | 동작 |
| --- | --- |
| 지원하지 않는 구문·타입, 전이 대상의 불일치 | 생성 시에 진단을 표시한다. 소스의 진단에는 파일명·행·열이 붙는다 |
| 게임 고유의 수리 조건 위반 | 조작 안에서 검사하고 `Step.Ignore`로 이유를 반환한다 |
| 입력 타입·수치 범위·enum 후보·record 필수 필드·리비전의 불일치 | 생성한 실행 코드에서도 검사한다. 내부 Retry로서 입력을 채택하지 않으며, 게임 고유의 Ignore 이유와 구별한다 |
| 대기 중에 받아들이지 않는 조작을 Session에서 호출한다 | 예외 |
| 0으로 나누기 등의 실행 오류 | 오류로 취급한다. Ignore와 달리 입력 전으로 돌아가 그대로 계속하는 것은 보장하지 않는다 |
| 생성 실패 | 이전에 성공한 생성물을 남긴다 |

Ignore와 실행 오류는 복귀 방법이 다릅니다. 일반적인 입력 거부는 예외로 표현하지 말고 Ignore를 사용해 주세요.



