본문으로 건너뛰기

3. 스크립트 모델 — 프로젝트 없는 .cs

「검증 파이프라인」으로 돌아가기


Roslyn(Microsoft.CodeAnalysis.CSharp)으로 컴파일해 프로세스 안에서 실행합니다.

C#인 이유 — 실제 작성자와 타입

기획자가 쓰기에 쉬운 언어는 아닙니다. 그 사실을 인정하고 시작합니다. 그런데도 C#인 이유는, 이 규칙을 실제로 쓰는 것이 프로그래머와 AI 에이전트이고 그 둘에게는 타입이 있는 쪽이 분명히 유리하기 때문입니다.

기존 검증 141개가 그것을 이미 보여줍니다. LootKindValidator의 보상 타입 38종 상수표, main.luaIsServer 의존 전파 고정점 반복, 워커 오케스트레이션과 중복 스킵 판정 — 기획자가 쓴 코드가 아닙니다. 검증은 시트 옆에 있지만 성격은 코드이고, 그것이 처음부터 프로그래머의 일이었습니다.

타입이 주는 것동적 언어에서 어떻게 되나
오타가 즉시 오류row.stackLimitrow.MaxStack이 둘 다 nil로 읽혀 조건이 아무 표시 없이 통과합니다. 검증이 도는 것과 검증이 아무것도 검사하지 않는 것을 구별할 방법이 없습니다
AI 에이전트에게 즉시 돌아오는 신호에이전트가 규칙을 쓸 때 필요한 것은 쓸 수 있는 것의 표면과 틀렸을 때의 신호입니다. 컴파일러가 그 자리에서 검토자 역할을 합니다 — 틀린 컬럼 이름·타입이 맞지 않는 비교·없는 enum 값이 실행 전에 걸립니다. 신호가 없으면 그럴듯하지만 아무것도 검사하지 않는 코드가 나옵니다
표면의 탐색테이블 275개와 그 컬럼 전부를 기억하는 것은 사람도 에이전트도 하지 못합니다. 자동 완성과 정의로 이동이 스키마를 컨텍스트에 밀어 넣는 대신 필요한 것만 조회하게 합니다
리팩터링컬럼 이름이 바뀌면 141개 파일 중 어디가 깨지는지 컴파일에서 나옵니다. 문자열 키였다면 실행해봐야 알고, 실행해도 위의 첫 줄 때문에 걸리는 것 없이 통과합니다
언어 경계가 없는 것변환기가 C#입니다. 스크립트에서 쓸 만한 것이 확인되면 코어 헬퍼로 승격하는 경로가 언어를 건너지 않습니다

기획자의 자리는 없어지지 않습니다. 다만 「쓰는 것」이 아니라 셋입니다.

무엇
간단한 규칙은 실제로 간단합니다 — foreach · if · Error 세 줄이고, 그 형태는 언어와 무관하게 읽힙니다
읽고 판단하는 것이 실질적인 참여입니다 — 「이 규칙이 내가 의도한 것인가」는 규칙을 쓴 사람이 답할 수 없습니다
셀 위치가 나오는 보고가 기획자 몫의 인터페이스입니다. 스크립트를 열지 않아도 어느 셀이 왜 잘못인지가 옵니다

대가는 둘입니다. 컴파일이 실행 시간에 더해지고(캐시로 완화 — §7), Roslyn이 이 기능이 추가하는 가장 큰 의존입니다. 자립 배포본의 크기에 얼마를 더하는지는 측정해야 알고, 그 수치가 나오기 전에는 켜고 끄는 옵션을 논하지 않습니다.

파일 형태 — 프로젝트 없는 .cs이고 .csx가 아닌 것

규칙 파일은 평범한 .cs이고, 클래스 하나에 Validate(<단계 컨텍스트>) 하나입니다. 프로젝트에 등재되지 않고, 호스트가 실행 시점에 컴파일합니다.

정통 프로젝트 소스규칙 파일 (.cs, 프로젝트 없음)Roslyn 스크립트 (.csx)
네임스페이스 · 클래스 · 진입점클래스와 Validate(<단계 컨텍스트>). 네임스페이스는 없습니다최상위 문장이 곧 본문
빌드가 컴파일합니다호스트가 실행 시점에 컴파일합니다호스트가 실행 시점에 컴파일합니다
using을 파일마다두 줄은 파일에, 나머지는 호스트가 global using으로ScriptOptions.WithImports
ProjectReference · PackageReference호스트가 MetadataReference로 줍니다#r
다른 파일과의 결합은 프로젝트가호스트가 같은 컴파일에 넣습니다#load

.csx를 고르지 않은 이유는 그 두 강점이 우리에게 필요 없다는 것입니다. #r#load는 호스트가 없는 실행을 위한 장치입니다 — 스크립트가 자기 의존을 스스로 선언해야 하는 상황의 것입니다. 여기에는 호스트가 있고, 참조와 공용 코드 결합을 그쪽이 이미 하므로 지시자로 적을 것이 없습니다.

.cs가 나은 점
의존이 하나 줄어듭니다Microsoft.CodeAnalysis.CSharp.Scripting이 필요 없습니다. 무게의 대부분은 그 아래의 CodeAnalysis.CSharp이므로 절약은 크지 않지만, 쓰지 않는 계층을 싣지 않습니다
편집기가 파일을 해석합니다. 이것이 결정적인 차이입니다 — .csx의 globals 객체 멤버는 편집기가 알 방법이 원리적으로 없고, 평범한 .cs 클래스는 프로젝트에 넣으면 그대로 분석됩니다 (아래)
머리말이 표준 문법입니다#load·#r 같은 스크립트 전용 지시자가 아니라 평범한 using 두 줄입니다

클래스로 감싸는 이유 — 자동 완성이 형태를 정했습니다

처음 형태는 클래스 없이 최상위 문장을 쓰는 파일이었습니다. 그것이 자동 완성을 얻지 못한다는 것을 실측으로 확인하였고, 그 측정이 지금 형태의 근거입니다.

한 프로젝트에 파일 셋을 넣고 각 파일에 존재하지 않는 심볼을 하나씩 두었습니다. 컴파일러가 그것을 지적하면 본문이 바인딩되고 있다는 뜻이고, 자동 완성은 그 바인딩의 결과물입니다.

규칙 파일 형태보고된 오류해석
최상위 문장CS8802 4개. CS0103 0개최상위 문장이 컴파일 단위 하나에만 허용되어, 나머지 파일은 본문이 바인딩되지 않습니다
클래스로 감싼 것 (지금)CS0103 6개 — 세 심볼 전부전부 분석됩니다

#:project로 프로젝트를 가리키는 파일 기반 프로그램 방식도 시도하였고 되지 않았습니다 — 그것은 C# 문법이 아니라 SDK가 컴파일 전에 걷어내는 지시자여서, 편집기의 Roslyn은 1행의 구문 오류로 보고 그 파일의 컴파일을 중단합니다. TablesCS0103입니다.

그래서 세 조합 중 하나만 됩니다: 평범한 클래스를, 프로젝트에 넣고, 액세서와 같이 컴파일하는 것입니다. 잃은 것은 「파일을 열면 바로 검사문」이라는 성질이고, 얻은 것은 컬럼 수백 개를 기억하지 않아도 되는 것입니다 (§10).

Validate이 컨텍스트를 인자로 받습니다. 정적 클래스에 using static으로 여는 방식도 되지만, 인자로 받는 편이 규칙 파일에 감춰진 것을 없앱니다 — 보고에 쓰는 것이 어디서 왔는지가 서명에 적혀 있고, context. 을 타이핑하면 무엇이 있는지 편집기가 열거합니다. 호스트가 몰래 열어주는 이름에 기대는 줄이 규칙 파일에 하나도 없습니다.

대가는 하나이고 실재합니다. .cs는 부모 프로젝트의 기본 glob에 걸릴 수 있습니다. 검증 폴더가 다른 C# 프로젝트 트리 안에 있으면 그 프로젝트가 규칙 파일을 걷어가고, Tables를 참조하지 못해 부모 빌드가 깨집니다. 대응은 검증 폴더를 프로젝트 트리 밖에 두는 것 — 시트 옆이 그 자리입니다 — 이고, 안에 두어야 한다면 부모에서 <Compile Remove>로 빼는 것입니다.

외부 어셈블리 플러그인이 아닌 이유는 타깃 플러그인을 만들지 않기로 한 이유와 같은 자리에 있습니다 — 빌드 절차와 버전 계약이 생기면 규칙 하나를 고치는 비용이 프로젝트를 열고 빌드하고 버전을 맞추는 일이 됩니다. 규칙 파일은 파일 하나가 전부이고, 컴파일러는 변환기 안에 있습니다.

폴더 규약

recipe가 가리키는 폴더 하나가 검증의 전부입니다.

validation/
rules/pre/ 사전 검증 — 파일 이름 · 설정 · 환경
rules/tables/ 테이블별 — 파일명이 `<테이블>Rules.cs` (ItemRules.cs → Item)
rules/global/ 전역 — 테이블 사이 · 테이블 개수 · 명명 규칙
rules/runtime/ 외부 저장소 교차 확인 — 이 폴더만 건너뛸 수 있습니다
rules/shared/ 공용 코드 — 호스트가 같은 컴파일에 넣고, 그 자체로 실행되지 않습니다
.generated/ 생성된 액세서. 실행마다 다시 쓰고, 버전 관리 제외

tables/에 있는데 그 이름의 테이블이 없으면 오류입니다 — 테이블이 이름을 바꾸면 검증이 말없이 안 도는 것이 아니라, 스크립트를 따라 옮기라고 안내해야 합니다.

규칙 한 파일의 형태

파일에 감춰진 것이 없습니다 — 쓰는 이름 둘이 using 두 줄로 적혀 있고, 진입점은 Validate 하나이고, 보고에 쓰는 것은 그것이 받는 인자입니다. 스캐폴딩이 이 형태를 씁니다(§11).

// validation/rules/tables/ItemRules.cs — 파일 이름이 곧 대상 테이블입니다.
using Tabbit.Rules; // Tables — 생성된 액세서
using Tabbit.Validation; // Context — 보고 · 설정 · 파일 · 저장소

internal static class ItemRules
{
public static void Validate(ITableContext context)
{
foreach (var row in Tables.Item.Records)
{
// 의뢰서 아이템은 스택되지 않아야 합니다.
if (row.Type == 7 && row.MaxStack != 1)
context.Error(row, nameof(row.MaxStack), "의뢰서 아이템의 maxStack은 1이어야 합니다.");

// 소모 버프 아이템의 버프 대상은 선단 또는 함대만입니다.
if (row.Type == 36)
{
var buff = Tables.WorldBuff.FindById(row.BuffId);
if (buff is null || buff.BuffTargetType is not (1 or 2))
context.Error(row, nameof(row.BuffId), "WorldBuff의 buffTargetType은 company 또는 fleet만 지원합니다.");
}
}
}
}

같은 규칙의 기존 Lua는 테이블 로드 보일러플레이트를 포함해 약 30줄이었고, 보고는 Item (10023) : ...까지였습니다. 여기서는 실패에 셀 위치가 나옵니다Error가 로우와 필드명으로 워크북·시트·셀 주소를 채웁니다. 구글 시트라면 그 셀로 가는 링크입니다.

Tables와 컨텍스트가 무엇을 내는지는 §「컨텍스트」에 있습니다 — globals 객체가 아니라 생성된 실재하는 타입과 그것을 받는 인자라, 편집기가 둘 다 해석할 수 있는 것이 그 선택의 이유입니다.

타입 액세서 — 검증용 C#의 무조건 생성

Tables.Item은 문자열 키 조회가 아닙니다. 사후 검증이 도는 시점에는 어떤 테이블이 어떤 필드를 갖는지 전부 확정되어 있으므로, C# 생성기를 그 자리에서 불러 액세서·레코드· enum 소스를 만들고, 그것을 먼저 컴파일한 뒤 모든 스크립트가 그것을 참조합니다.

recipe가 csharp을 요청했는지와 무관하게 생성합니다.

recipe의 상태검증이 하는 일
csharp 타깃 있음그 항목의 설정(네임스페이스·액세서 이름)으로 생성하거나 그 산출물을 사본으로 씁니다. 스크립트가 보는 타입이 게임 코드가 보는 타입과 같은 이름입니다
csharp 타깃 없음기본 설정으로 검증용으로만 생성합니다. 산출물 트리에는 아무것도 들어가지 않습니다

코드 생성기는 Model에서 소스 텍스트를 만드는 단계이고 파일로 쓰는 것만 타깃의 일이므로, 검증이 타깃 실행을 기다릴 필요가 없습니다 — §1의 순서가 그대로 유지됩니다. 같은 생성기를 부르는 것이라 두 경우의 소스도 같습니다.

데이터는 메모리 왕복입니다. 생성된 리더는 .tcb를 읽는 코드이므로, 바이너리 익스포터를 메모리에서 돌려 그 바이트를 리더에 직접 넘깁니다. 파일은 쓰지 않습니다 — 생성된 C#의 Tables.ReadAllBytesAsync가 대입 가능한 자리라 그대로 됩니다.

이 구조가 주는 것
게임과 같은 코드로 데이터를 봅니다. 검증이 통과한 형태가 곧 게임이 읽는 형태입니다
참조가 해석된 상태입니다 — 리더가 연결 단계를 수행하므로 row.Owner.Name처럼 따라갑니다
오타가 컴파일 오류입니다. Tables.Itme · row.MaxStak은 실행 중의 드러나지 않는 미스가 아니라 파일·줄 번호가 찍힌 스크립트 컴파일 오류입니다
필드가 실제 타입입니다. row.MaxStackint, enum 컬럼은 생성된 enum 타입 — row.Grade == Grade.Legend로 비교합니다
생성된 코드·리더·형식이 실제로 동작하는지 매 실행이 확인합니다. 적합성 코퍼스가 경계값으로 하는 일을 실제 데이터로 한 번 더 하는 것입니다
IDE 지원 — 액세서가 validation/.generated/의 실재하는 소스이므로 자동 완성이 가리킬 대상이 있습니다. 편집기용 프로젝트가 그 소스와 규칙 파일을 같이 컴파일하고, 규칙 파일이 클래스인 덕에 전부 해석됩니다 (§11)

대가는 둘입니다. 직렬화와 되읽기가 한 번 더 듭니다 — 컬럼 지향 형식의 쓰기·읽기는 측정된 가장 빠른 경로라 임포트·쿠킹 옆에서는 작지만 0은 아닙니다. 그리고 생성된 레코드에는 셀 위치가 없습니다 — 데이터만 들고 있기 때문입니다.

셀 위치는 컨텍스트가 되찾습니다. context.Error(row, nameof(row.MaxStack), …)에서 컨텍스트는 로우의 기본 인덱스 값과 필드 이름으로 Model의 원래 셀을 조회합니다. 인덱스의 유니크는 정적 검증이 이미 보장하고 필드 이름은 생성 규칙의 역이므로, 보고는 여전히 워크북 : 시트 : AF12 를 가리킵니다. 레코드가 위치를 들고 다니게 하는 대신 컨텍스트가 조회하는 쪽을 고른 것은, 생성되는 코드를 검증 때문에 바꾸지 않기 위해서입니다 — 게임이 쓰는 코드와 스크립트가 쓰는 코드가 같아야 위 표의 첫 줄이 성립합니다.

컨텍스트 — 루트 둘

하나로 두려고 하였으나 구현이 그것을 허락하지 않았고, 갈라진 결과가 더 낫습니다.

Tables.Item // 데이터 — 생성된 액세서, 게임 코드가 쓰는 그 타입
context.Schema.Tables // 메타 — 열거되는 뷰
context.Error(row,) // 보고
context.Option("Locale") // recipe에서 온 값
루트무엇
Tables — 타입C# 생성기가 내는 액세서 그 자체입니다. 생성되는 클래스 이름이 이미 Tables이고 멤버가 테이블마다의 정적 프로퍼티라, Tables.Item게임 코드가 쓰는 문장과 같은 문장입니다
컨텍스트Validate이 받는 인자보고·설정·파일·저장소. 실행마다 새로 만들어지고, 어느 규칙 파일이 보고했는지·상한에 걸렸는지 같은 실행 상태를 그 안에 듭니다. 타입은 폴더가 정합니다 — 아래 「단계마다 다른 컨텍스트」

context.Tables가 되지 않았던 이유는 액세서가 정적 클래스이고, 정적 클래스가 다른 클래스의 프로퍼티가 될 수 없다는 것이었습니다. 액세서를 객체화한 뒤로는 그 제약이 없습니다 (액세서 인스턴스) — 남은 것은 계약 어셈블리가 아직 존재하지 않는 타입을 선언할 수 없다는 것뿐이고, 그것은 생성된 어셈블리 쪽에서 잇습니다.

컨텍스트를 인자로 넘기는 이유는 규칙 파일의 암묵적인 것을 없애는 것입니다. 정적 클래스를 using static으로 여는 방식도 되고 한동안 그 형태였지만, 그러면 Error(...)가 어디서 왔는지가 파일에 적혀 있지 않습니다. 인자로 받으면 서명이 그것을 나타내고, context. 을 타이핑하면 무엇이 있는지 편집기가 열거합니다. 규칙이 쓸 수 있는 것의 목록이 곧 그 인자의 멤버 목록입니다.

버린 것이유
globals 객체 (Roslyn 스크립팅)globals 멤버는 IDE가 알 방법이 원리적으로 없어 Rows.를 입력해도 아무것도 나오지 않습니다. 생성된 타입과 평범한 인자는 둘 다 편집기가 봅니다
Rows (대상 테이블의 줄임)globals 멤버로만 가능한 유일한 이름이었습니다 — 그 하나를 위해 자동 완성이 안 되는 통로를 유지할 값이 없습니다. Tables.Item으로 통일하면 규칙이 대상을 스스로 말하기도 합니다
정적 컨텍스트와 using staticError가 어디서 왔는지 파일에 나타나지 않고, 실행 상태를 AsyncLocal로 들어야 했습니다. 인자 하나가 둘을 다 없앱니다

파일 이름과 테이블의 바인딩은 그대로입니다 — 어느 테이블의 규칙이 어디 있는지를 파일 목록이 정하고, 그 이름의 테이블이 없으면 오류이며, 병렬 실행의 단위도 그것입니다.

단계마다 다른 컨텍스트

넷이 같은 타입을 받던 것을 폴더마다 다른 타입으로 갈랐습니다. 규칙이 무엇을 쓸 수 있는지가 타입에 적혀 있게 하는 것이 목적입니다 — 전에는 폴더에 맞지 않는 것을 불러도 컴파일되었고, 실행 중에야 「어느 폴더로 옮기라」는 메시지가 나왔습니다.

폴더타입더해지는 것
rules/pre/IPreContext보고(메시지만) · Option · Files · Json
rules/global/IGlobalContext : IPreContextSchema, 행·컬럼을 짚는 보고
rules/tables/ITableContext : IGlobalContextTable — 그 파일이 담당하는 테이블
rules/runtime/IRuntimeContext : IGlobalContextDb · Redis

겹치게 둔 이유rules/shared/의 헬퍼입니다. 실제로 쓰는 것 중 가장 좁은 타입을 받으면 그 아래 단계 전부에서 불립니다 — 넷이 나란히 있으면 헬퍼마다 오버로드가 넷 필요했을 것입니다.

tables/global/과 갈리는 지점은 Table 하나입니다. 파일 이름이 이미 대상 테이블을 말하므로, 규칙이 Schema.Table("Item")으로 이름을 한 번 더 적지 않게 하는 것입니다 — 이름을 바꿀 때 찾아야 할 자리가 하나 줄어듭니다.

호스트 객체 하나가 넷을 다 구현합니다. 캐스팅으로 우회할 수 있다는 뜻이고, 그래서 Db의 단계 확인은 남겨 둡니다 — 타입은 실수를 막고, 실행 시 확인이 경계입니다.

컨텍스트의 멤버무엇
Tables타입 액세서의 루트. 모든 테이블이 프로퍼티입니다 — Tables.WorldBuff
Schema테이블·필드를 열거하는 메타 뷰. 대상이 이름으로 특정되지 않는 규약 룰셋이 쓰는 자리 (§4)
Error(row, nameof(row.Field), msg[, at])커밋을 차단하는 보고. 로우와 필드명에서 셀 위치가 나옵니다 — nameof라 필드명도 컴파일이 확인합니다. at은 배열·레코드 그룹의 원소
ErrorAtRow(row, msg)여러 컬럼이 함께 잘못일 때. 로우의 기본 인덱스 셀을 가리킵니다
Error(field, msg) · Error(table, msg)스키마 뷰의 대상에 대한 보고. 위치는 컬럼 헤더 셀과 테이블 마커 셀입니다
Warn(…) · Info(…)같은 오버로드 집합의 다른 심각도. 아래 표가 셋의 차이입니다
Option(key) · Option(key, 기본값) · HasOption(key)recipe Validation.Options의 자유 키/값. 로케일·콘텐츠 경로처럼 코어가 몰라야 하는 것이 지나가는 자리. 기본값 없는 조회에서 없는 키는 오류입니다 — 빈 문자열로 답하면 아무것도 검사하지 않는 규칙이 됩니다
Files(root, pattern)폴더 하나를 이름으로 조회하는 맵. 확장자와 대소문자를 무시하고, 폴더·패턴마다 한 번만 스캔합니다
Json(path)테이블이 아닌 일반 JSON 읽기 — 기존 검증이 보던 콘텐츠 파일 같은 것. 캐시합니다
Db(name) · Redis(name)recipe Validation.Connections의 이름으로 여는 읽기 전용 게이트웨이. runtime/에서만 — 그 밖에서 부르면 어느 폴더로 옮기라는 메시지가 나옵니다 (§6)

검사 유틸을 따로 두지 않았습니다. Lua 헬퍼 6종(CheckRequired·CheckLink·CheckMin·CheckMax· CheckEnum·CheckRequiredInObject)의 39개 호출부는 정적 검증과 타입 액세서가 이미 답하므로 옮길 것이 없었고, 남는 조건부 규칙은 if 한 줄이라 헬퍼가 오히려 한 겹입니다. 필요해지면 그때 넣습니다.

레코드 그룹·배열·옵셔널의 액세서 표현은 C# 생성기의 형태(row.Slot[k].Id · row.HasBuffId)을 따릅니다 — 세부는 구현 단계에서 실제 검증을 이식하며 확정합니다(§9).

심각도 셋 — Error · Warn · Info

심각도커밋위치무엇을 적나
Error차단합니다필수 — 셀 위치가 나옵니다데이터가 규칙을 위반한 것
Warn막지 않습니다. TreatWarningsAsErrors로 승격 가능필수잘못이라고 단정할 수 없지만 봐야 하는 것 — 고아 데이터, 설명 없는 테이블
Info막지 않고, 승격도 없습니다선택검증이 무엇을 했는지 — 대조한 건수, 적용하지 않은 규칙과 그 이유, 집계 결과

승격되지 않는 것이 Info를 경고와 가르는 선입니다. 승격될 수 있는 것은 판정이고, Info는 판정이 아니라 기록입니다. 위치가 선택인 것도 같은 이유입니다 — 셀에 대한 말이 아니라 실행에 대한 말이 대부분입니다.

// 규칙을 적용하지 않았다면 그 사실을 남깁니다. 조용히 넘어가는 것과 다릅니다.
if (context.Option("Locale") != "KR")
{
context.Info("현금 판매 규칙은 KR에서만 적용됩니다. 이 실행은 건너뜁니다.");
return;
}

Info는 Serilog의 Information으로 나가므로 기본 실행에서 보이고 --silent에서 숨습니다. 요약에는 세 심각도의 개수가 남습니다 — 「검증이 돌았는가」를 개수가 답하게 하기 위해서이고, 조용한 검증은 통과와 구별되지 않습니다.

수집은 기존 Diagnostics를 심각도 셋으로 넓혀 그대로 씁니다 — 하나 고치고 다시 돌리는 대신 한 번에 전부 보고하는 성질, 위치를 들고 throw하는 성질이 이미 그 클래스의 것입니다. 보고 함수가 정적이어도 되는 것은 보고에 스크립트 단위의 상태가 없기 때문입니다 — 위치는 인자가 들고 오고, 수집은 스레드 안전한 한 곳입니다.

사전 검증 스크립트가 받는 것

pre/의 컨텍스트에는 Model이 없습니다 — 아직 읽지 않았기 때문입니다. 대신 recipe의 뷰와 소스별 파일 목록을 받습니다: 파일 이름 규칙(「워크북 이름이 정해진 접두어로 시작」), 있어야 하는 환경 변수, recipe 설정 사이의 정합(「binary와 csharp의 확장자가 같은가」— 다만 이런 것 중 일반적인 것은 코어의 정적 검증으로 승격하는 편이 맞고, 사전 스크립트는 그 프로젝트만의 규칙을 위한 자리입니다).