3. 스크립트 모델 — 프로젝트 없는 .cs
Roslyn(Microsoft.CodeAnalysis.CSharp)으로 컴파일해 프로세스 안에서 실행합니다.
C#인 이유 — 실제 작성자와 타입
기획자가 쓰기에 쉬운 언어는 아닙니다. 그 사실을 인정하고 시작합니다. 그런데도 C#인 이유는, 이 규칙을 실제로 쓰는 것이 프로그래머와 AI 에이전트이고 그 둘에게는 타입이 있는 쪽이 분명히 유리하기 때문입니다.
기존 검증 141개가 그것을 이미 보여줍니다. LootKindValidator의 보상 타입 38종 상수표,
main.lua의 IsServer 의존 전파 고정점 반복, 워커 오케스트레이션과 중복 스킵 판정 —
기획자가 쓴 코드가 아닙니다. 검증은 시트 옆에 있지만 성격은 코드이고, 그것이 처음부터
프로그래머의 일이었습니다.
| 타입이 주는 것 | 동적 언어에서 어떻게 되나 |
|---|---|
| 오타가 즉시 오류 | row.stackLimit과 row.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행의 구문 오류로 보고
그 파일의 컴파일을 중단합니다. Tables가 CS0103입니다.
그래서 세 조합 중 하나만 됩니다: 평범한 클래스를, 프로젝트에 넣고, 액세서와 같이 컴파일하는 것입니다. 잃은 것은 「파일을 열면 바로 검사문」이라는 성질이고, 얻은 것은 컬럼 수백 개를 기억하지 않아도 되는 것입니다 (§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.MaxStack은 int, 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 static | Error가 어디서 왔는지 파일에 나타나지 않고, 실행 상태를 AsyncLocal로 들어야 했습니다. 인자 하나가 둘을 다 없앱니다 |
파일 이름과 테이블의 바인딩은 그대로입니다 — 어느 테이블의 규칙이 어디 있는지를 파일 목록이 정하고, 그 이름의 테이블이 없으면 오류이며, 병렬 실행의 단위도 그것입니다.