본문으로 건너뛰기
SheetForge

플러그인 작성 — Core 수정 없이 도메인 추가하기

도메인(skills, items, quests 등)은 SheetForge.Core를 참조하는 별도의 패키지로서 SheetForge에 합류한다 — Core는 결코 그것을 되돌아 참조하지 않는다. 플러그인은 enum, 커스텀 셀 타입, 래퍼 타입, 도메인 검증기, 그래프 엣지, 구조 마커, "시트 생성" 템플릿, 임포트 소스 전체, 데이터 스튜디오의 캔버스 오버라이드, 코드 레지스트리, 선언적 작성 표면, 위젯과 액션, 컬러 프리셋, 커스텀 셀 위젯, 파이프라인 관찰자, 그리고 자신만의 현지화된 UI 문자열까지 추가할 수 있다 — 아래의 열여섯 가지 계약이다.

"도메인 추가 = Core 코드 변경 없음"은 컴파일러로 강제된다: InternalsVisibleTo가 없는 테스트 어셈블리(SheetForge.Tests.Consumer)가 공개 표면만을 사용해 열여섯 가지 중 열다섯 가지 — 그리고 그 옆의 capability 인터페이스들 — 를 구현하므로, 그중 무엇이라도 internal로 좁혀진다면 빌드가 실패할 것이다(CS0122). 열여섯 번째, 즉 에디터 전용 리치 패널 탈출구는 VisualElement를 반환하므로 대신 에디터 측 테스트로 검증된다.

열한 개의 Core 계약은 순수 C#이다. 이 덕분에 컴파일된 플러그인 DLL 하나가 Unity 에디터 그리고 브라우저(SheetForge Web) 양쪽에서 동일한 슬롯을 켤 수 있다 — 어셈블리와 격리는 하나의 공유 Core 함수이며, 오직 발견만 호스트별로 다르다(Unity의 TypeCache, 브라우저의 업로드된 어셈블리 스캔).

다섯 개의 Editor 계약은 UIToolkit 엘리먼트를 반환하거나 창 상태를 건드리므로, 에디터에만 존재한다.

열여섯 가지 모두 자동으로 발견된다 — 매개변수 없는 생성자가 요구 사항의 전부이며, 어셈블리 참조도, 등록 호출도, 편집할 매니페스트도 없다:

계약등록 대상선택 여부
ISheetForgePluginEnum + 커스텀 셀 타입 파서기본 계약
ISheetForgeValidatorPlugin도메인 검증 규칙(컬럼 간 / 탭 간)선택적 애드온
ISheetForgeEdgePlugin코어 스캐너가 볼 수 없는 그래프 엣지 선언선택적 애드온
ISheetForgeMarkerPlugin커스텀 구조 마커(컬럼별 @marker 행)선택적 애드온
ISheetForgeTemplatePlugin"시트 생성" 템플릿(탭 + 예제 데이터)선택적 애드온
ISheetForgeGraphPlugin데이터 스튜디오를 위한 탭별 캔버스 오버라이드선택적 애드온
ISheetForgeCodeRegistryPlugin코드에 존재하는 읽기 전용 키 공간을, 잠긴 가상 탭으로선택적 애드온
ISheetForgeThemePluginSheetForge 창을 위한 컬러 프리셋(다크와 라이트)선택적 애드온
ISheetForgeStudioPlugin선언적 작성 표면 — 액션, 패널, 컬럼 배지, 셀 에디터 힌트선택적 애드온
ISheetForgeStringsPlugin언어별 자신의 팩 UI 문자열(제품 테이블보다 먼저 참조되는 오버레이)선택적 애드온
ISheetForgePipelinePlugin파이프라인 관찰자 — 임포트가 만든 것에 대한 읽기 전용 알림선택적 애드온
ISheetSourceProvider임포트 소스 전체(DB / REST / 자체 시스템)독립적(Editor 어셈블리)
IStudioGraphWidget데이터 스튜디오 캔버스 위의 도메인 위젯독립적(Editor 어셈블리)
IStudioInspectorAction데이터 스튜디오 노드 인스펙터의 추가 버튼독립적(Editor 어셈블리)
IStudioCellEditorProvider데이터 스튜디오 그리드의 특정 셀 타입을 위한 커스텀 입력 위젯독립적(Editor 어셈블리)
IStudioPanelProvider스튜디오 안의 임의의 UIToolkit 패널 — 선언적 방식 옆의 탈출구독립적(Editor 어셈블리)

참조 샘플은 선택적 임포트다. 완전한 예제(SheetForge.PluginDemo)는 Assets/SheetForge/Examples/SheetForgePluginDemo.unitypackage에 Unity 패키지로 제공된다 — 더블클릭하거나, 시작하기 창(Tools ▸ SheetForge ▸ Getting Started, 데모 임포트가 있는 유일한 곳)에서 플러그인 데모 임포트를 누르면 Assets/SheetForge.PluginDemo/… 아래에 복원된다. 임포트하기 전까지는 프로젝트 안에 아예 존재하지 않는다 — 이 샘플은 오직 그 패키지로만 제공된다 — 그래서 이 패키지의 어셈블리/타입/탭/주소는 사용자의 프로젝트와 결코 충돌하지 않는다. 아래에서 참조하는 경로(Assets/SheetForge.PluginDemo/ModifierCellParser.cs 등)는 패키지를 임포트한 이후에 존재한다. (플러그인이 없는 두 번째 샘플 — SheetForge.CoreDemo — 는 코어 내장 타입만으로 파이프라인을 보여준다.)

애드온은 기본 인터페이스를 변경하지 않으면서 확장한다 — 검증이나 엣지가 필요 없는 플러그인은 이들의 존재에 전혀 영향받지 않는다.

일곱 개의 인터페이스는 계약이 아니라 capability다:

  • 이들은 스스로 발견되지 않는다.
  • 이들은 이미 등록된 무언가에 의해 추가로 구현된다.
  • Core는 그 등록된 객체를 캐스팅해서 이들을 찾아낸다.

여섯 개는 등록된 엣지 기여자 또는 캔버스 오버라이드로부터 캐스팅된다 — 발견 규칙과 각각의 내용은 §4.12를 참고하라. 일곱 번째인 IReferencingCellType은 등록된 셀 파서로부터 캐스팅되며, 자신의 표기법에 RecordId@Tab이 받는 것과 동일한 참조 처리를 부여한다 — §4.4a를 참고하라. 이들 중 무엇을 무시하든 아무것도 바뀌지 않는다.

1. 패키지 설정

SheetForge.Core를 참조하는 자체 .asmdef를 가진 폴더를 생성한다(런타임 조회가 필요하다면 SheetForge.Runtime도 추가). 그게 전부다 — Editor의 PluginRegistryTypeCache를 통해 사용자의 ISheetForgePlugin 구현을 발견하고 등록 메서드를 호출한다. 등록은 어셈블리 스캔이 아니라 사용자가 명시적으로 작성한 코드다.

Core 계약 구현은 그 메인 어셈블리 안에 둔다. 에디터 측 컴패니언 어셈블리(SheetForge.Editor도 함께 참조)는 다섯 개의 IStudio* / ISheetSourceProvider 구현이 들어가는 곳이다 — 브라우저는 사용자의 메인 DLL만 로드하므로, 에디터 컴패니언에 구현된 Core 계약은 그곳에서 조용히 누락될 것이다.

1.1 호환성 선언하기 (선택, 한 줄)

어셈블리 수준 애트리뷰트는 이 어셈블리가 플러그인 형식의 어느 세대를 대상으로 빌드되었는지, 그리고 원하는 최저 호스트 버전을 명시한다:

using SheetForge.Core.Plugins;
 
[assembly: SheetForgePluginCompat(
    SheetForgePluginFormat.Current,          // the generation constant of the SDK you compiled against
    MinHostVersion = "0.1.0",                // optional — omit for "any host"
    PluginVersion  = "1.0.0")]               // optional, display only
  • 생략해도 괜찮다. 선언이 없는 어셈블리는 호스트 요구 사항이 없는 세대 SheetForgePluginFormat.Minimum으로 읽히므로, 이 애트리뷰트가 존재하기 전에 작성된 플러그인도 이전과 정확히 동일하게 로드된다.
  • 판단 단위는 어셈블리다, 그리고 거부된 어셈블리는 등록 전체를 잃는다. 타입별 선언이었다면 선언하지 않은 이웃 타입을 통과시켜 "거부되었지만 절반은 등록됨"이라는 상태를 남길 것이다.
  • 판정자는 카탈로그가 아니라 DLL이다. 마켓 레지스트리는 다운로드 전에 목록을 필터링할 수 있도록 동일한 두 값(pluginFormat, minHost)을 광고하지만, 게이트는 검증된 바이트에서 애트리뷰트를 직접 읽는다 — 목록은 틀릴 수 있어도, 컴파일된 선언은 그럴 수 없다.
  • 거부는 조용한 소멸이 아니라, 어셈블리가 무엇을 선언했고 이 호스트가 무엇을 읽는지 명명하는 PluginIncompatible 진단이다. 이는 호환성 선언이지 서명이 아니다: 무결성은 배포 채널의 몫이다(웹 플러그인 마켓 참고).
  • 세대 번호는 플러그인 형식 자체가 교체될 때만 움직인다. 순수하게 추가되는 성장 — 새 계약, 레지스트리의 새 멤버 — 은 결코 이를 움직이지 않는다. 기존 플러그인이 재컴파일 없이 계속 실행되기 때문이다.

2. 기본 플러그인: enum + 커스텀 셀 타입

using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
 
public sealed class SkillsPlugin : ISheetForgePlugin
{
    public string Name => "Skills";   // for diagnostics / duplicate-conflict reports
 
    public void RegisterEnums(EnumRegistry enums)
    {
        // Any Enum<ActionType> / Enum<EffectType> cell in a sheet now resolves,
        // and codegen emits the real CLR enum type on the generated field.
        enums.Register<ActionType>();
        enums.Register<EffectType>();
    }
 
    public void RegisterCellParsers(CellParserRegistry parsers)
    {
        // A custom cell type joins parsing, validation, codegen, bake and
        // round-trip by registration alone (open-closed — zero pipeline edits).
        parsers.Register(new ModifierCellParser());
    }
}

커스텀 셀 타입 전체 과정

ICellValueParser(문자열 → 값)를 구현하고, 강타입 베이크 그리고 Export/Push 라운드트립을 완성하려면 ICustomCellType(CLR 타입 + 값 → 정규 문자열)도 구현한다. 샘플의 Modifier 미니 문법(stat:op:value, 예: attack:add:10):

using System;
using SheetForge.Core.Model;
using SheetForge.Core.Unparse;
 
public sealed class ModifierCellParser : ICellValueParser, ICustomCellType
{
    // The @type cell text: a column declares "Modifier" or "List<Modifier>".
    public string TypeName => "Modifier";
 
    // ICustomCellType: the CLR value type codegen emits ([Serializable] struct).
    public Type ValueType => typeof(Modifier);
 
    public bool TryParse(CellParseContext context, string text, out object value)
    {
        value = null;
        string[] parts = text.Split(':');
        if (parts.Length != 3)
        {
            // Failure = collect a structured error and return false. Never throw.
            context.Errors.Add(new ImportError(
                ImportErrorCode.CustomTypeParseFailed, context.Coordinate,
                text, "'stat:op:value' form (e.g. attack:add:10)", null));
            return false;
        }
        // ... parse the three parts (InvariantCulture; reject NaN/Infinity) ...
        value = new Modifier(parts[0].Trim(), /*op*/ default, /*value*/ 0f);
        return true;
    }
 
    // ICustomCellType: value → canonical cell string (the exact inverse of TryParse).
    public bool TryRender(object value, out string text, out string reason)
    {
        reason = null;
        if (!(value is Modifier m)) { text = null; reason = "Not a Modifier."; return false; }
        // Use CanonicalValueRenderer.RenderFloat for floats — round-trip-safe on Mono.
        text = m.stat + ":" + "add" + ":" + CanonicalValueRenderer.RenderFloat(m.value);
        return true;
    }
}

(op 토큰 검증과 최근접 일치 제안을 포함한 완전한 프로덕션 버전은 Assets/SheetForge.PluginDemo/ModifierCellParser.cs를 참고하라.)

커스텀 타입에 대한 @target은 등록만으로 동작한다: 컬럼을 Modifier@Stats로 선언하면 파서가 context.Type.TargetName("Stats")을 읽는다. 그 대상의 무결성 검사(탭이 존재하는가? id가 해석되는가?)는 도메인 검증기의 몫이다 — RecordId@Tab과 동일한 역할 분담이다. @가 붙었지만 등록되지 않은 타입 이름도 여전히 제안과 함께 에러가 되므로, 오타 안전성이 유지된다.

Core가 이미 소유한 타입 이름. 내장 스칼라 이름 — int, float, bool, string, Enum, RecordId, IntId, AssetRef, Color, AnimationCurve, Gradient — 은 어떤 플러그인보다도 먼저 등록되어 있다. 이 중 하나를 재사용하는 파서는 PluginRegistrationConflict로 등록에 실패한다 — 내장 타입이 그대로 남고, 그 RegisterCellParsers 호출은 충돌한 파서에서 멈추지만, 그 플러그인의 다른 슬롯들은 계속 로드된다 — 그러므로 자신만의 ColorGradient 타입을 담아 배포한 팩은 이름을 바꿔야 한다(체인지로그의 업그레이드 노트 참고). 사용자의 타입이 색상, 커브, 그라디언트를 저장하는 것이라면 그 표기법을 다시 구현할 필요가 없다: Core의 값 모델 ColorValue, CurveValue, GradientValueTryParse(text, out value, out error)Render()를 노출하고, CurveEvaluator / GradientEvaluator는 Unity와 정확히 동일하게 이를 샘플링하며, ColorPicker, CurveEditor, GradientEditor 아키타입(§4.16)을 가진 StudioCellEditorHint는 두 호스트 모두에서 사용자 타입을 위한 네이티브 에디터를 연다.

래퍼 타입 전체 과정 (MyWrapper<T>)

래퍼는 여러 개의 내부 T 값을 하나의 셀에 담는 제네릭 값 형태다 — Pair<int> = 1~2. 사용자는 오직 외부 문법(구분자, 인자 수)만 소유하면 된다. Core가 내부 T를 재귀적으로 파싱하므로, Pair<RecordId@Effects>, Pair<Enum<DamageType>>, 중첩된 Box<Pair<int>> 모두 추가 코드 없이 파싱되고 검증되며, 내부의 참조도 완전히 검증된다. ICellWrapperType을 구현하고 동일한 RegisterCellParsers 훅에서 parsers.RegisterWrapper(...)를 통해 등록한다:

// A [Serializable] generic value type — codegen emits Pair<int>, Pair<RecordRef>, ...
[Serializable] public struct Pair<T> { public T First; public T Second; public Pair(T a, T b){First=a;Second=b;} }
 
public sealed class PairWrapper : ICellWrapperType
{
    public string Name => "Pair";                 // the @type token: Pair<Inner>
    public Type OpenClrType => typeof(Pair<>);     // generic open type — exactly one type parameter
 
    // Outer syntax only: split "1~2" into ["1","2"]. Use a delimiter OTHER than ';'
    // so List<Pair<T>> doesn't clash with the list separator.
    public bool TrySplit(string cell, out IReadOnlyList<string> pieces, out string reason)
    {
        reason = null;
        var parts = (cell ?? "").Split('~');
        if (parts.Length != 2) { pieces = null; reason = "'a~b' form (two parts)."; return false; }
        pieces = new[] { parts[0], parts[1] };
        return true;                               // the Core parses each piece as the inner type
    }
 
    public string JoinCanonical(IReadOnlyList<string> inner) => inner[0] + "~" + inner[1]; // inverse of TrySplit
 
    public object Assemble(IReadOnlyList<object> inner, Type closed) =>
        Activator.CreateInstance(closed, inner[0], inner[1]);      // bake: build Pair<TInner>
 
    public bool TryDisassemble(object v, out IReadOnlyList<object> inner, out string reason)
    {
        reason = null;
        var t = v.GetType();
        inner = new[] { t.GetField("First").GetValue(v), t.GetField("Second").GetValue(v) };
        return true;                               // Export: read the values back out (inverse of Assemble)
    }
}
 
// In your ISheetForgePlugin.RegisterCellParsers:
public void RegisterCellParsers(CellParserRegistry parsers) => parsers.RegisterWrapper(new PairWrapper());

그 등록 하나로 다음이 모두 갖춰진다:

  • 재귀적 @type 해석.
  • 강타입 코드젠(Pair<RecordRef> First;).
  • 베이크.
  • Export/Push 라운드트립.
  • 참조 통과 처리 — 래퍼 안의 RecordId@Tab은 무결성이 검사되고, 키 이름 변경 시 전파되며, 탭 이름 변경 시 재작성된다.

거부 규칙과 ; 구분자 관련 유의 사항은 시트 문법에 문서화되어 있다.

3. 도메인 검증기 (선택)

Core 검증은 네 가지 종류(키, 참조, @overlap, 에셋 키)로 고정되어 있다. 컬럼 간 규칙("type이 Custom이면 script가 필수다")이나 탭 간 규칙(참조된 레코드의 의미를 확인하는 것)에는 ISheetForgeValidatorPlugin을 구현한다:

using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
using SheetForge.Core.Validation;
 
public sealed class SkillsPlugin : ISheetForgePlugin, ISheetForgeValidatorPlugin
{
    // ... Name / RegisterEnums / RegisterCellParsers unchanged ...
 
    public void RegisterValidators(DomainValidatorRegistry validators)
    {
        validators.Register(new CustomEffectRequiresScriptValidator());
    }
}
 
public sealed class CustomEffectRequiresScriptValidator : IDomainValidator
{
    public string Name => "CustomEffectRequiresScript";
 
    public void Validate(DomainValidationContext ctx)
    {
        if (!ctx.Tables.TryGetValue("ExampleEffects", out var effects)) return;
        if (!effects.Schema.TryGetField("script", out var scriptField)) return;
        foreach (var rec in effects.Records)
        {
            if (!(rec["type"].Value is EnumValue ev) || ev.MemberName != "Custom") continue;
            var scripts = rec["script"].AsList;
            if (scripts != null && scripts.Count == 0)
                ctx.Errors.Add(new ImportError(ImportErrorCode.DomainRuleViolation,
                    new CellCoordinate("ExampleEffects", rec.RowNumber, scriptField.ColumnNumber, "script"),
                    /* what */ rec["codeName"].Value.ToString(),
                    /* why  */ "A Custom effect must specify a script to run, but 'script' is empty.",
                    /* how  */ "Put a script address in the 'script' column, or change 'type'."));
        }
    }
}

등록된 검증기는 임포트 검증과 작성 사전 검증 양쪽 모두에 자동으로 합류한다. 규칙:

  • 위반 사항은 ImportErrorCode.DomainRuleViolation으로 ctx.Errors에 보고한다 — 결코 throw하지 않는다(예외를 던지면 격리되어 승격되지만, 다른 검증기는 계속 실행된다).
  • 네 가지 요소를 모두 채운다 — 어디서(CellCoordinate), 무엇이(ActualValue), 왜(Expected), 어떻게(Suggestion). "어떻게"는 조치 가능한 문장으로 그대로 표시된다.
  • ctx는 다음을 제공한다:
    • 파싱된 모든 테이블(Tables),
    • 키 인덱스(KeyIndices),
    • 에셋 키(AssetKeysnull은 에셋 검증이 생략되었음을 의미).
  • 모든 것 수집하기와 부분 조립 없음은 자동으로 상속된다.

4. 엣지 기여자 (선택)

데이터 그래프 위에 툴링을 구축하거나(또는 향후 그래프 캔버스가 도메인의 연결을 볼 수 있기를 원한다면), 코어 참조 스캐너가 볼 수 없는 엣지를 선언한다 — 예를 들어 미니 문법 값 안에서 참조되는 스탯:

public sealed class SkillsPlugin : /* ... */, ISheetForgeEdgePlugin
{
    public void RegisterEdgeContributors(EdgeContributorRegistry contributors)
    {
        contributors.Register(new ModifierStatEdgeContributor()); // effect → stat edges
    }
}

IEdgeContributor는 읽기 전용 탭 간 컨텍스트를 받아 EdgeSpec 항목(from/to 탭 + 레코드 id, 선택적 필드, 페이로드 레코드, 라벨)을 추가한다. 기여자는 결코 진단 정보를 발생시키지 않는다 — 엣지는 검증이 아니라 프로젝션 소재다. 작성 커널 참고.

4.4 레시피: 내부에 키를 담은 커스텀 타입

RecordId@Tab은 Core가 이해하는 유일한 참조 형태이며, 무결성 검사·그래프 엣지·최근접 일치 제안·이름 변경 전파를 공짜로 얻는다. 자신만의 표기법이 키를 삼키는 순간 — attack:add:10, stat.hp>50, fire@0.4 — Core는 하나의 불투명한 문자열만 보게 되므로, 그 네 가지 서비스는 사용자의 문 앞에서 멈춘다. 세 가지 등록이 그중 셋을 되돌려준다. 이들을 하나의 집합으로 작성하라. 셋 중 하나만 있는 미니 문법은 "임포트는 잘 되는데 아무것도 아무 데도 가리키지 않는다"는 결과를 낳는 형태다.

조각계약복원하는 것없으면
1. 무결성IDomainValidator(§3)자신의 표기법 안에 있는, 존재하지 않는 키가 좌표와 조치 가능한 문장과 함께 보고된다오타가 깨끗하게 임포트되었다가 런타임에 실패한다
2. 가시성IEdgeContributor(§4)묻혀 있던 링크가 실제 엣지가 된다: 캔버스가 이를 그리고, Used by 목록이 이를 세며, 참조 인덱스가 이를 색인한다연결은 데이터 안에 존재하지만 화면 어디에도 없다
3. "어떻게"조각 1 안의 TextSuggestion.FindNearest"알 수 없는 스탯 'atack'. 'attack'을 의도한 것인가요?" — 내장 참조 에러가 사용하는 것과 동일한 문장 형태진단은 정확하지만 조치할 방법이 없다
// Piece 1 + 3 together — the validator is where the suggestion belongs, because it is the
// only one of the three that produces a sentence a person reads.
using SheetForge.Core.Model;
using SheetForge.Core.Validation;
 
public sealed class ModifierStatExistsValidator : IDomainValidator
{
    public string Name => "ModifierStatExists";
 
    public void Validate(DomainValidationContext ctx)
    {
        if (!ctx.KeyIndices.TryGetValue("Stats", out var stats)) return;   // no target tab: nothing to check
        if (!ctx.Tables.TryGetValue("Effects", out var effects)) return;
        if (!effects.Schema.TryGetField("modifier", out var field)) return;
 
        foreach (var rec in effects.Records)
            foreach (string statKey in StatKeysIn(rec["modifier"]))       // your notation's own split
            {
                if (stats.Contains(statKey)) continue;
                string near = TextSuggestion.FindNearest(statKey, stats.Keys);   // piece 3
                ctx.Errors.Add(new ImportError(ImportErrorCode.DomainRuleViolation,
                    new CellCoordinate("Effects", rec.RowNumber, field.ColumnNumber, "modifier"),
                    /* what */ statKey,
                    /* why  */ "This modifier points at a stat that does not exist in 'Stats'.",
                    /* how  */ near != null
                        ? "Did you mean '" + near + "'? Fix the stat name in the modifier value."
                        : "Add that record to 'Stats', or correct the stat name."));
            }
    }
}

표기법을 위한 분리 로직은 하나만 재사용하라 — 파서, 검증기, 엣지 기여자는 키가 어디서 시작해서 어디서 끝나는지에 대해 합의해야 하며, 그 분리 로직을 세 벌의 개별 사본으로 두면 서로 어긋나게 된다. (래퍼 타입(§2)은 이를 공짜로 얻는다: TrySplit 공유되는 분리 로직이다.)

네 번째 서비스 — 이름 변경 전파 — 는 한 가지가 더 필요하며, 이를 얻는 방법은 두 가지다. 레코드 이름 변경은 Core가 텍스트 안에서 키를 찾을 수 있는 곳에서만 참조하는 셀을 재작성한다. 이는 RecordId@Tab 필드, 그 리스트, 그리고 TrySplit이 키를 하나의 요소로 노출하는 래퍼에 대해서는 가능하다 — 하지만 사용자 문법의 부분 문자열 경계를 스스로 추측할 수는 없다. 그러니 다음 둘 중 하나를 선택하라:

  • 어떻게 하는지 알려주기IReferencingCellType(§4.4a)을 구현하면 이 세 조각짜리 레시피 전체가 하나의 옵트인으로 대체되며 네 가지 서비스가 한 번에 복원된다.
  • 그 경계를 받아들이기 — 이는 적어도 조용한 것보다는 정직하다: 조각 1이 다음 임포트에서 좌표와 제안과 함께 이제 매달린 키를 보고한다.

위의 레시피는 한 가지 경우에는 여전히 옳은 답이다: 컬럼에 @target이 없을 때 — 키가 사는 단일 탭이 없기 때문이다. 번들 샘플이 정확히 그런 경우다 — List<Modifier>는 대상을 명명하지 않으므로 Core는 attack이 어디로 해석되어야 하는지 알 수 없고, ModifierStatEdgeContributor가 그 엣지들을 손으로 연다. 컬럼에 대상을 주면(List<Modifier@Stats>) §4.4a가 대신 맡는다.

4.4a 자신의 표기법에 완전한 참조 등가성 부여하기 (선택)

이미 등록한 파서에 **IReferencingCellType**을 구현하면, MyType@Tab 컬럼은 더 이상 특별한 경우가 아니게 된다: RecordId@Tab과 정확히 동일하게 검증되고, 제안되고, 전파되고, 그려지고, 선택되고, 색인된다.

새로운 등록 채널은 없다. Core는 CellParserRegistry에 이미 있는 파서를 캐스팅한다 — 캔버스 capability가 등록된 엣지 기여자로부터 캐스팅되는 것과 같은 방식이다(§4.12). 이를 구현하지 않는 커스텀 타입은 이전과 정확히 비트 단위로 동일하게 동작한다.

다섯 개의 훅

다섯 개의 훅은 모두 요소 하나를 대상으로 동작한다: 스칼라 컬럼이라면 셀 전체, List<MyType@Tab>이라면 ;로 구분된 요소 하나 — 사용자의 ICellValueParser.TryParse가 받는 것과 동일한 단위다.

답하는 질문쓰이는 곳
bool TryGetTokenKey(elementText, out key)"이 요소는 무엇을 가리키는가?"소속 여부 — 이 셀이 이미 그 레코드에 연결되어 있는가
string MakeToken(key)"이 키에 대한 새 링크를 작성하라"빈 셀, 또는 리스트에 추가하는 경우. 페이로드를 중립적인 시작점으로 채운다 — 작성 표면은 결코 값을 지어내서는 안 된다. null/빈 값을 반환하면 그 제스처는 거짓으로 꾸며지는 대신 사유와 함께 비활성화된다
bool TryRetargetToken(elementText, newKey, out newText)"이것을 다른 무언가로 향하게 하라" 셀에서 다른 레코드를 선택하는 것, 그리고 캔버스에서 와이어를 재조준하는 것. 대상만 바꾼다 — 토큰을 제거했다가 다시 만들면 사람이 입력했던 숫자가 초기화될 것이다
bool TryRemoveToken(elementText, key, out newText)"이것의 연결을 끊어라"빈 텍스트를 반환하면 요소가 사라진다(스칼라 셀은 비워지고, 리스트 요소는 제거된다). 비어 있지 않은 값을 반환하면 그만큼은 남는다
bool TryRewriteKeys(elementText, renames, out newText)"이 키들을 모두 치환하라"이름 변경 일괄 처리. TryRetargetToken과는 별개다 — 그것은 사람의 단일 지시인 반면 이것은 일괄 처리이기 때문이다 — 그리고 두 개의 참조를 담은 요소는 둘 다 재작성해야 한다

텍스트 쪽과 값 쪽

파싱된 값에도 **IRefBearingValue**를 추가하라 — 두 절반은 서로 다른 일을 하며 둘 다 필요하다. 텍스트 쪽은 파싱된 값을 볼 수 없고, 값 쪽은 작성자가 입력한 표기법을 복원할 수 없다:

using System.Collections.Generic;
using SheetForge.Core.Model;
 
// Text half — on the parser. `stat:op:value`, e.g. attack:add:10
public sealed class ModifierCellParser : ICellValueParser, ICustomCellType, IReferencingCellType
{
    public bool TryGetTokenKey(string t, out string key)
    {
        key = Head(t);                                  // the first segment is the reference
        return key.Length != 0;
    }
 
    public string MakeToken(string key) => key + ":add:0";                 // neutral, ready to edit
 
    public bool TryRetargetToken(string t, string newKey, out string newText)
    {
        newText = newKey + Rest(t);                     // the residue is preserved
        return Head(t).Length != 0;
    }
 
    public bool TryRemoveToken(string t, string key, out string newText)
    {
        newText = string.Empty;                         // nothing is left without the key
        return Head(t) == key;                          // not ours → false, never overwrite blindly
    }
 
    public bool TryRewriteKeys(string t, IReadOnlyDictionary<string, string> renames, out string newText)
    {
        newText = t;
        if (!renames.TryGetValue(Head(t), out string to)) return false;
        newText = to + Rest(t);                         // attack:add:10 → power:add:10
        return true;
    }
    // … TypeName / TryParse / ValueType / TryRender as in §2
}
 
// Value half — on the value the parser produces.
public struct Modifier : IRefBearingValue
{
    public string stat; public string op; public float value;
 
    IEnumerable<string> IRefBearingValue.ReferencedKeys =>
        string.IsNullOrEmpty(stat) ? System.Array.Empty<string>() : new[] { stat };
}

인터페이스를 구현해도 필드가 추가되지 않으므로, 베이크된 ScriptableObject와 생성된 코드는 변하지 않는다.

하나의 옵트인으로 얻는 것 — 이들 각각은 재구현이 아니라 Core 자신의 코드 경로다:

  • 무결성 + 제안 — 존재하지 않는 키는 좌표와 "혹시 이것을 의도했나요…"와 함께 UnresolvedRecordId로 보고되며, 내장 참조와 컬럼당 제안 예산을 공유한다.
  • 페이로드가 그대로 보존되는 이름 변경 전파attackpower로 이름 변경하면 attack:add:10power:add:10으로 재작성된다. 연산자와 숫자는 작성자의 것이며, 그대로 살아남는다.
  • 그래프 — 링크가 좌표를 가진 실제 엣지가 된다: 그려지고, 노드가 포트를 받으며, Used by 목록이 이를 세고, 참조 인덱스가 양방향으로 이를 가진다.
  • 피커 — 셀은 RecordId@Tab 셀이 가진 것과 동일한 검색 가능한 드롭다운을 받으며, 다른 레코드를 선택하면 대상만 교체되고 나머지는 유지된다. 이 등록이 없으면 피커는 값 위에 순수한 키를 덮어쓰는 대신 거부한다.
  • 고아 탐지와 export되는 드롭다운 규칙 — 유일한 외부 링크가 자신의 표기법 안에 있는 행은 더 이상 연결되지 않은 것으로 취급되지 않으며, 자신의 타입을 가진 스칼라 컬럼은 대상 탭의 키에 대한 데이터 검증 드롭다운을 받는다(소스, 내보내기 및 Push).

가장 단순한 사용법은 별칭(alias) 타입이다. 값이 그냥 키이고 셀 텍스트가 그 키라면:

  • TryGetTokenKey는 다듬기만 한다.
  • MakeToken은 키를 그대로 반환한다.
  • TryRetargetToken은 새 키를 반환한다.
  • TryRemoveToken은 빈 값을 반환한다.

그 컬럼은 기능적으로 완전히 RecordId@Tab이며, 사용자에게 남은 유일한 몫은 표현이다: @type에 자신만의 이름으로 나타나며, 그 컬럼 하나에만 셀 위젯(§4.13)이나 캔버스 도형(§4.7)을 붙일 수 있다. 별칭에는 별도의 계약이 필요 없다.

두 가지 제약이 있으며, 둘 다 구조적이다:

  • 페이로드에 ;를 넣을 수 없다. Core는 사용자의 파서나 이 훅들 중 무엇이든 텍스트를 보기 전에 리스트 셀을 요소로 분리하므로, 값 안의 세미콜론은 두 요소로 조각날 것이다. (래퍼 타입도 동일한 이유로 동일한 제약을 갖는다.)
  • @target은 실제 시트 탭을 가리켜야 한다, RecordId@Tab과 정확히 동일하게 — 코드 레지스트리의 가상 탭은 UnknownTargetTab으로 거부된다. 이 제약이 있기에 미해결 참조 보고, 최근접 일치 제안, 이름 변경 전파가 Core 자신의 것 그대로, 수정 없이 적용될 수 있다.

다섯 개의 훅 중 무엇도 throw해서는 안 된다: 해석할 수 없는 것에는 falsenull로 답하고, 재작성할 때는 항상 나머지를 보존하라.

정수 키 공간에 대해서도 동작한다. 사용자의 @target이 명명하는 탭이 RecordId가 아니라 IntId로 키를 잡는다면, 사용자의 코드에서는 아무것도 달라지지 않는다 — 훅이 주고받는 키는 그저 텍스트로 쓰인 정수일 뿐이다. 어느 키 공간과 비교할지는 사용자의 타입이 아니라 대상 탭 자신의 아이덴티티가 결정한다.

  • 검증, 최근접 일치 제안, 이름 변경 전파, 엣지, 피커, 고아 탐지 모두 동일한 방식으로 작동한다.
  • Core가 그곳에서 대신 챙겨주는 사소하지만 유용한 점 하나: 정수는 여러 방식으로 표기될 수 있으므로, 이름 변경은 TryRewriteKeys에 정본 표기와 함께 그 요소에 나타난 그대로의 표기를 함께 건네준다(0077 둘 다 12로 매핑된다) — 그래서 사용자 타입 안의 순서형(ordinal) 조회가 앞자리가 채워진(padded) 값을 놓치지 않는다.
  • 번들 데모에는 IntId 탭을 겨냥하는 참조형 커스텀 타입이 포함되어 있지 않다 — 그 Modifier 예제는 문자열 키 탭을 대상으로 한다 — 그래서 이 경로에는 테스트는 있지만 그대로 베낄 수 있는 예제는 없다.

4.5 커스텀 구조 마커 (선택)

내장 마커는 @name, @type, @desc, 그리고 선택적인 세 가지다:

  • @overlap.
  • @style — 컬럼이 아니라 시트 자체(그 그룹 라벨과 색상)를 서술한다.
  • @enum — 시트를 테이블이 아니라 enum 정의 집합으로 표시한다.

@overlap컬럼별 마커다: 그 행은 컬럼당 하나의 값을 담고, 컬럼별로 검증된다. 동일한 방식으로 자신만의 마커를 등록할 수 있다 — 예를 들어 각 숫자 컬럼이 어떻게 보간되는지 기록하는 @curve 마커. IStructuralMarkerDefinition을 구현하고 ISheetForgeMarkerPlugin을 통해 등록한다:

// A hypothetical plugin (the bundled Plugin Demo does not register a marker):
public sealed class CurvesPlugin : /* ... */, ISheetForgeMarkerPlugin
{
    public void RegisterStructuralMarkers(MarkerRegistry markers)
    {
        markers.Register(new CurveMarker());
    }
}
 
public sealed class CurveMarker : IStructuralMarkerDefinition
{
    public string MarkerName => "curve";                 // without '@' → the sheet row is @curve
    public string Description => "How this column interpolates (linear/ease/step).";
 
    // Validate this column's @curve cell. Empty is allowed (defaults to linear).
    public void ValidateCell(MarkerCellContext context)
    {
        string v = context.RawText.Trim();
        if (v.Length == 0) return;                        // you decide what an empty cell means
        if (v != "linear" && v != "ease" && v != "step")
            context.Reject("@curve must be linear, ease, or step", "use one of: linear, ease, step");
    }
}

그러면 시트는 @curve 행을 받아들인다(데이터 위라면 어떤 순서든):

@name    | level | atk
@type    | int   | int
@curve   |       | ease
         | 1     | 10
  • 이 값은 도메인 무관 메타데이터로 저장된다: field.MarkerValues["curve"]. 도메인 검증기 또는 엣지 기여자는 이를 context.Tables[tab].Schema.Fields[i].MarkerValues에서 읽으며, 작성 창은 컬럼 헤더 툴팁에 이를 보여준다.
  • 거부된 셀은 MarkerCellInvalid 진단 정보가 된다 — 사용자가 "왜"와 "어떻게 고치는지"를 제공하고, Core가 좌표와 문제가 된 값을 제공한다.
  • 마커 이름은 유효한 식별자여야 하며 내장 여섯 개(@name/@type/@desc/@overlap/@style/@enum)와 충돌해서는 안 된다(그렇지 않으면 Register가 예외를 던지며, PluginRegistrationConflict로 나타난다).
  • 마커는 새로운 데이터 형태가 아니라 컬럼별 메타데이터를 위한 것이다 — 마커는 자신의 셀 검증만 소유할 뿐, 행 전체를 소유하지 않는다. 커스텀 마커 행은 export/라운드트립 시 그대로 보존되며, 모든 구조 편집(추가/삭제/이동/이름 변경)에서 자신의 컬럼과 함께 이동한다.
  • 코드젠은 마커 값을 베이크하지 않는다(@overlap처럼, 이들은 검증/표시용 메타데이터일 뿐이며 스키마 핑거프린트에는 보이지 않는다).

4.6 "시트 생성" 템플릿 (선택)

시트 생성 흐름은 두 가지 내장 템플릿 — 코어 타입만 사용하는 items 시트, 그리고 @enum 정의 시트 — 와 "처음부터"를 제공한다. 사용자만의 enum, 커스텀 타입, 참조를 사용하는 도메인 템플릿 — 시트 골격 — 은 플러그인에서 온다. 그래서 사용자의 플러그인이 존재할 때만 나타난다. ISheetForgeTemplatePlugin을 구현한다:

public sealed class SkillsPlugin : /* ... */, ISheetForgeTemplatePlugin
{
    public void RegisterTemplates(TemplateRegistry templates)
    {
        templates.Register(new DataTemplate(
            "skills.demo",                                   // registry key (unique; duplicates rejected)
            "Skill demo (Actions · Effects · Skills)",       // your own display string
            new List<DataTemplateTab>
            {
                // Each tab carries a full TSV: marker rows + example data.
                new DataTemplateTab("Actions", "@name\tcodeName\ttype\n@type\tRecordId\tEnum<ActionType>\n\tfireball\tProjectile"),
                new DataTemplateTab("Effects", /* ... */ ""),
                new DataTemplateTab("Skills",  /* ... */ ""),
            }));
    }
}
  • 템플릿은 하나 이상의 탭을 담으며, 각 탭은 완전한 정규화된 TSV다(주석/마커 행 플러스 예제 데이터) — 0행 골격인 내장 item 예제와는 다르다. 사용자의 도메인 타입이 이미 등록되어 있으므로(플러그인이 로드된 상태이므로), 생성된 시트는 바로 재임포트에 성공한다.
  • 표시 문자열은 사용자의 것이다. 플러그인은 자신의 텍스트를 직접 소유한다(예제 패키지는 도메인 어휘 가드 밖에 있다) — Core Loc 키에 제한되지 않는다.
  • 다중 탭 템플릿은 자신의 탭을 모두 생성하고 한 번만 재임포트하므로, 탭 간 참조가 함께 해석된다. Create 패널은 이런 템플릿에 대해서는 탭 이름 필드를 숨긴다(탭 이름은 템플릿에 의해 고정된다).
  • 키, 빈 표시 이름, 탭이 하나도 없는 경우, 빈 탭 TSV는 거부된다(Register가 예외를 던지며, PluginRegistrationConflict로 나타난다).

4.7 탭별 캔버스 오버라이드 (선택)

데이터 스튜디오의 캔버스는 무엇을 그릴지 스스로 결정한다: 레코드 하나를 열면 — 그것이 종착점이다 — 참조 인덱스를 바깥으로 따라가며 그 레코드가 소비하는 모든 것을 모은 다음, 그 결과를 왼쪽에서 오른쪽으로 배치한다. 이는 플러그인이 전혀 없어도 동작한다.

플러그인이 추가하는 것은 코어가 볼 수 없거나 알 수 없는 것이다:

  • 시트 레코드가 아닌 아이덴티티;
  • RecordId@Tab 컬럼에 쓰여 있지 않은 링크;
  • 참조 깊이가 아니라 도메인 규칙에 따른 순서.

IRecordCanvasAugmenter를 구현하고 ISheetForgeGraphPlugin을 통해 탭별로 등록한다:

using SheetForge.Core.Graphing;
using SheetForge.Core.Plugins;
 
public sealed class SkillsPlugin : /* ... */, ISheetForgeGraphPlugin
{
    public void RegisterGraphShapes(GraphShapeRegistry shapes)
    {
        shapes.Register("ExampleActions", new ExampleReactiveAugmenter());   // tab name → override
    }
}
 
public sealed class ExampleReactiveAugmenter : IRecordCanvasAugmenter
{
    public void Augment(GraphBuildContext context, CanvasAugmentBuilder builder,
                        string terminusTab, string terminusRecordId)
    {
        // context = Tables (parsed sheets) · References (indexed both ways) · CodeRegistries
 
        // ① A virtual node: an identity that is not a sheet record. The tab may be empty —
        //    then the key alone identifies it. The last argument is where clicking it jumps.
        builder.AddNode(string.Empty, "evt:impact_landed", "impact_landed", "event");
 
        // ② An extra edge the core scanner cannot see (this link lives in a plain string column).
        //    Naming the field says *which cell* it is written in; leaving it out keeps the wire
        //    display-only. Direction is "A uses B", and B is drawn to the left of A.
        builder.AddEdge(terminusTab, terminusRecordId, string.Empty, "evt:impact_landed",
                        /*label*/ "listen", /*fieldName*/ "listen");
 
        // ②b An edge drawn one way whose cell lives on the other end, and a loop you know about.
        //     Both are trailing arguments — the short call above still compiles unchanged.
        builder.AddEdge(string.Empty, "evt:impact_landed", terminusTab, terminusRecordId,
                        label: "raises", fieldName: "raises", fieldOnTarget: true,
                        isCyclic: true, cyclicNote: "brake 0s — no damping");
 
        // ③ A layer hint. Absolute columns count from 0 at the left (negative goes further left);
        //    relative columns count from the terminus, which is what a fixed stage usually means.
        builder.SetLayerRelative(string.Empty, "evt:impact_landed", -2);
 
        // ④ A display hint: what a human calls this record. Only you know which column is a name.
        builder.SetSubtitle(terminusTab, terminusRecordId, "Counter strike");
    }
}
  • 등록은 탭 이름 단위다. 등록하지 않은 탭도 여전히 캔버스 — 코어의 클로저 — 를 받으므로, 플러그인이 모든 시트를 커버할 필요는 결코 없다. 중복된 탭, 빈 탭 이름, null 오버라이드는 거부된다(Register가 예외를 던지며, PluginRegistrationConflict로 나타난다).
  • 추가할 뿐, 대체하지 않는다. 어떤 레코드가 나타나는지는 클로저의 답이다. (tab, key)가 이미 화면에 있는 가상 노드는 버려진다 — 실제 레코드가 이긴다 — 그래서 오버라이드는 시트에 존재하는 레코드를 지어낼 수 없다. 할 수 있는 것은 시트 행이 전혀 없는 아이덴티티를 데려오는 것이다.
  • 이름은 유일한 예외다. 표시 힌트는 아이덴티티가 아니라 표현이므로, 이미 존재하는 레코드에도 실제로 적용되며, 화면에 전혀 없는 레코드의 이름을 붙일 수도 있다 — 연결 피커가 그것들을 읽으므로, 카드의 부제와 피커 행이 같은 것을 말하는 이유다. 빈 이름은 무시되며(이는 "기본값을 사용한다"와 같다), 한 레코드에 대한 첫 번째 이름이 우선한다.
  • 엣지는 자신의 노드를 함께 데려온다. 추가 엣지의 한쪽 끝이 화면에 없으면, 링크가 결코 매달리지 않도록 노드로 추가된다. 어느 한쪽 끝의 키가 빈 엣지는 무시된다.
  • 셀이 있는 곳과 화살표가 가리키는 곳은 다를 수 있다. 기본적으로 fieldName이 명명한 셀은 출발 레코드에 있다고 가정된다. 대신 도착 쪽에 있다면 fieldOnTarget: true를 전달한다 — 발행된 이벤트 와이어는 이벤트 → 레코드로 그려지지만, 텍스트는 레코드 자신의 컬럼에 있다. 그러면 와이어 인스펙터는 아무것도 아닌 것이 아니라 진짜 셀을 가리킨다.
  • 순환: 코어는 자신이 볼 수 있는 것을 표시하고, 사용자는 자신이 아는 것을 선언한다. 사용자의 추가 엣지가 루프를 닫으면, 캔버스는 그 백 엣지를 알아서 분류하고 점선으로 그린다. 순환이 문제인지 판단하는 것은 도메인 검증기의 몫이다(§3) — 캔버스는 표시 소재일 뿐, 결코 검증이 아니다.
    • isCyclic은 레이아웃을 건드리지 않고 와이어를 표시상 순환으로 표시한다.
    • cyclicNote는 사용자만 아는 것(예를 들어 감쇠 값)을 담는다 — 라벨은 컬럼 이름으로 유지하고 설명은 note에 넣는다.
  • 레이어 힌트는 두 가지 종류다.
    • SetLayer는 절대적이다 — 컬럼 0이 가장 왼쪽이고, 음수는 더 왼쪽으로 간다.
    • SetLayerRelative는 종착점으로부터 센다(−1은 그 바로 왼쪽 컬럼이다). 이는 대개 고정된 단계가 의미하는 바다: 그러면 체인이 얕든 깊든 그림은 동일하게 읽히며, 단계들이 충돌하지 않도록 종착점 자체를 고정할 필요가 없다.
    • 상대적 힌트는 어떤 힌트가 이동시키기 전의 종착점 컬럼을 기준으로 해석되므로, 힌트를 추가하는 순서가 결과를 바꿀 수 없다. 결과 위치가 원점보다 왼쪽으로 가면 그림 전체가 오른쪽으로 이동한다.
    • 화면에 없는 노드에 대한 힌트는 버려지며, 한 노드에 대한 첫 번째 힌트가 우선한다.
  • 실패는 격리된다. Augment는 try/catch 안에서 실행된다: 예외는 영어 콘솔 경고가 되고 코어의 그림만 남을 뿐, 결코 창이 깨지지 않는다.
  • 확장은 결코 사용자를 깨뜨리지 않는다. 첫 릴리스 이후 추가된 모든 capability는 후행 인자이거나 새 메서드다 — 더 이른 표면을 대상으로 작성된 오버라이드는 그대로 컴파일되고 동일하게 동작한다.

(완전한 오버라이드는 Assets/SheetForge.PluginDemo/Graphing/ExampleReactiveAugmenter.csExamplePipelineAugmenter.cs를 참고하라 — 레코드 주위에 이벤트 노드와 코드 블록을 덧붙이는 reaction, 그리고 고정된 단계가 자신의 컬럼에 고정되는 cast.)

4.8 코드 레지스트리 — 코드 안에 존재하는 참조 대상 (선택)

일부 참조 대상은 시트에서 전혀 작성되지 않는다: 런타임이 디스패치하는 실행 원자들이다. 이들을 잠긴 가상 탭으로 등록하면 작성 표면에 읽기 전용으로 올라오며, 이들을 가리키는 엣지가 깨진 것으로 그려지지 않게 된다. ISheetForgeCodeRegistryPlugin을 구현한다:

using System.Collections.Generic;
using SheetForge.Core.Graphing;
using SheetForge.Core.Plugins;
 
public sealed class SkillsPlugin : /* ... */, ISheetForgeCodeRegistryPlugin
{
    public void RegisterCodeRegistries(CodeRegistryCatalog catalog)
    {
        catalog.Register(new CodeRegistrySource("_Refs", new List<CodeRegistryEntry>
        {
            // key = the referenceable id · label = shown text · raises = optional related keys
            new CodeRegistryEntry("action.projectile", "Projectile launch", new[] { "impact_landed" }),
            new CodeRegistryEntry("effect.script",     "Script effect",     null),
        }));
    }
}
  • 세 가지 소비 지점:
    • 데이터 스튜디오 사이드바는 가상 탭을 읽기 전용 아래 key/label/raises 그리드로 보여준다;
    • 캔버스 오버라이드는 context.CodeRegistries를 통해 항목을 조회할 수 있다;
    • 그리고 노드 인스펙터는 항목의 Raises를 나열한다.
  • 이 키들은 스튜디오의 존재 여부 검사에 합류한다. 대상이 등록된 키인 엣지 — 보통 IEdgeContributor(§4)가 선언했거나 사용자의 shape가 만든 것 — 는 깨진 참조로 칠해지지 않는다.
  • 임포트 검증기는 가상 탭을 모른다. 코드 레지스트리는 작성 표면의 개념이므로, 시트 컬럼을 RecordId@_Refs로 타이핑하지 마라(임포트가 UnknownTargetTab을 보고할 것이다). 데모가 하는 방식 — type 컬럼과 엣지 기여자/shape 조회 — 으로 시트 데이터를 코드 원자에 연결하라.
  • 실제 시트와 충돌할 수 없는 이름을 선택하라(데모는 _를 접두사로 사용한다). 충돌이 발생하면, 스튜디오는 어느 한쪽을 조용히 숨기는 대신 사이드바에 그 충돌을 배지로 표시한다.
  • 거부되는 경우: null source, 빈 탭 이름, 또는 중복된 탭 이름은 예외를 던진다(PluginRegistrationConflict로 나타남). nullRaises 목록은 빈 목록으로 정규화된다. Core는 key / label / raises를 불투명한 문자열로 취급한다 — 결코 이를 해석하지 않는다.

(Assets/SheetForge.PluginDemo/Graphing/ExampleCodeAtoms.cs를 참고하라.)

4.9 데이터 스튜디오 그래프 위젯 (선택, Editor 어셈블리)

위젯은 그래프 캔버스 위에 놓이는 사용자만의 UI 스트립이다 — 고정된 단계 개요, 집계 배지, 도메인이 원하는 무엇이든. 코어는 위젯을 전혀 제공하지 않으므로, 이 영역은 플러그인이 채우기 전까지 비어 있다. 반환 타입이 VisualElement이므로, 이 계약은 Editor 어셈블리에 있다(ISheetSourceProvider와 동일하게 정당화된 비대칭이다). SheetForge.EditorSheetForge.Core를 참조하는 Editor 측 어셈블리에 구현한다:

using SheetForge.Editor.Studio;
using UnityEngine.UIElements;
 
public sealed class ExampleStageStripWidget : IStudioGraphWidget
{
    // context = Tab · ShapeId · ModeId · FocusRecordId · FocusRecord · Tables · References · CodeRegistries
    public bool AppliesTo(StudioGraphContext context) =>
        context.Tab == "ExampleSkills" && context.FocusRecord != null;
 
    public VisualElement Create(StudioGraphContext context)
    {
        var strip = new VisualElement();
        strip.Add(new Label("VALIDATE → CAST → COMMIT → DELIVER → APPLY"));
        return strip;                       // return null to add nothing
    }
}
  • 발견은 자동이다TypeCache가 매개변수 없는 생성자를 가진 모든 구현을 찾아낸다. 등록 호출도, 바인딩할 레지스트리도 없다. 인스턴스화 실패는 로그로 남고 건너뛰어진다.
  • 계약상 읽기 전용이다. 컨텍스트는 파싱된 테이블, 참조 인덱스, 코드 레지스트리를 노출하지만 — 스테이징 표면은 없다. 그래프로부터의 작성은 인스펙터 액션(§4.10)의 몫이며, 그것이 이를 중재한다.
  • 엘리먼트 안에 상태를 두지 마라. 위젯은 그래프가 재구축될 때마다 다시 생성된다 — 상태는 사용자 자신의 객체에 보관하라. 재구축은 키 입력이 아니라 사람의 행동 빈도로 합쳐진다.
  • 예외는 격리된다AppliesTo / Create가 throw하면 영어 콘솔 경고가 발생하며, 그래프는 계속 그려진다.

(Assets/SheetForge.PluginDemo/Demo/Editor/ExampleStageStripWidget.cs를 참고하라.)

4.10 데이터 스튜디오 인스펙터 액션 (선택, Editor 어셈블리)

액션은 노드 인스펙터 위의 추가 버튼이다 — "이 도메인이 이 레코드로 무엇을 할 수 있는가". 코어는 내장 액션 하나(Go to this sheet)를 제공하며, 그 외의 모든 것은 이 계약을 통해 도착한다:

using SheetForge.Editor.Studio;
 
public sealed class ExampleInspectorAction : IStudioInspectorAction
{
    // A Loc key. The demo registers this key's sentences per language (§4.14);
    // an unregistered key is displayed verbatim, so plain text also works.
    public string LabelKey => ExampleLocStrings.BrakeActionKey;
 
    public bool AppliesTo(StudioInspectorContext context) =>
        context.Tab == "ExampleActions" && context.Record != null;
 
    public void Execute(StudioInspectorContext context)
    {
        // Mediated mutation: the window turns this into one Undo step + one staged edit
        // carrying the logical address (tab · record id · field).
        context.StageCell(context.Tab, context.RecordId, "brakeSeconds", "0.25");
        // Show the user what changed: (tab, original sheet row number, field); row 0 = tab only.
        context.FocusCell(context.Tab, context.Record.RowNumber, "brakeSeconds");
        context.RequestRebuild();
    }
}
  • 작성 세션은 의도적으로 노출되지 않는다. 모든 스테이징 변경은 프로젝션 세대가 증가하는 하나의 네이티브 Undo 단계여야 하며, 원본 세션을 그대로 내주면 그 규칙을 우회하는 방법이 제도화될 것이다. StageCell(tab, recordId, field, rawText)StageCells(writes)가 변경 표면의 전부이며, 창이 그 장부를 소유한다.
  • 여러 셀을 바꾼다면? StageCells를 사용하라. context.StageCells(new[] { new EdgeCellWrite(tab, recordId, field, text), … })는 전체 목록을 하나의 Undo 단계로, 전부 아니면 전무로 스테이징한다(하나의 쓰기가 적용될 수 없으면 아무것도 적용되지 않는다). StageCell을 여러 번 호출하면 Ctrl+Z가 그 횟수만큼의 단계로 쪼개진다 — 그리고 병렬 컬럼의 경우 이는 undo 도중에 절반만 유효한 상태가 나타난다는 뜻이다. null이거나 빈 목록은 아무 일도 하지 않는다.
  • 정본 텍스트를 전달하라. 스테이징된 텍스트는 반영 시점에 임포터가 사용하는 것과 동일한 파서로 파싱된다 — 그러니 시트가 담을 내용을 써라.
  • baseline에 없는 키는 아무 일도 하지 않는다(완전히 새롭거나 해석되지 않은 레코드): 조용히 기록되는 것은 아무것도 없다.
  • 서비스: FocusCell은 그리드를 특정 좌표로 스크롤하고, RequestRebuild는 무언가를 스테이징한 뒤 새로고침을 요청한다.
  • 발견, 라벨, 격리는 위젯과 정확히 동일하게 동작한다: TypeCache 발견, 등록되지 않은 LabelKey에 대한 그대로 표시하는 폴백(빈 키는 타입 이름으로 대체된다), 그리고 AppliesTo / Execute를 감싸는 try/catch.

(Assets/SheetForge.PluginDemo/Demo/Editor/ExampleInspectorAction.cs를 참고하라. 그 Editor 어셈블리 — SheetForge.PluginDemo.Demo.Editor — 는 SheetForge.Editor, SheetForge.Core, 그리고 플러그인 어셈블리를 참조한다. 이것이 Editor 측 확장에 필요한 연결의 전부다.)

4.11 컬러 프리셋 (선택)

SheetForge는 작은 어휘의 컬러 슬롯(표면, 선, 텍스트, 시맨틱 색상, 스테이징 표시)으로 자신의 창을 칠한다. 프리셋은 자신이 관심 있는 슬롯만 다시 칠한다 — 그 외의 모든 슬롯은 제품 기본값을 유지한다. ISheetForgeThemePlugin을 구현한다:

public sealed class SkillsPlugin : /* ... */, ISheetForgeThemePlugin
{
    public void RegisterThemes(ThemeRegistry themes)
    {
        themes.Register(new SheetForgeTheme(
            "skills.forge",                       // registry key (unique; the built-in ids are reserved)
            "Forge (Skill demo)",                 // your own display string
            new Dictionary<ThemeColorSlot, uint>  // dark screens
            {
                { ThemeColorSlot.Accent, 0xff9a4d },
                { ThemeColorSlot.Canvas, 0x120d0a },
                { ThemeColorSlot.Text,   0xe8dccf },
            },
            new Dictionary<ThemeColorSlot, uint>  // light screens
            {
                { ThemeColorSlot.Accent, 0x9c4a10 },
                { ThemeColorSlot.Canvas, 0xf7f2ec },
                { ThemeColorSlot.Text,   0x2b1f16 },
            }));
    }
}
  • 색상은 0xRRGGBB다. Core는 엔진 타입을 전혀 참조하지 않으므로 여기에 UnityEngine.Color는 없다 — 최상위 바이트는 무시된다. 반투명 표면(배지 채우기, 모달 스크림)은 슬롯 색상에 고정된 알파를 더해 파생된다 — 사용자는 알파가 아니라 색상을 설정한다.
  • 두 화면 모두를 제공하라. 다크 맵과 라이트 맵을 제공한다. 사용자의 밝기 선택(에디터 따르기 / 항상 다크 / 항상 라이트)이 그중 하나를 고른다. 빠뜨린 슬롯은 그 밝기의 제품 기본값으로 대체되므로, 세 개짜리 슬롯 프리셋도 완전히 정상이다.
  • 등록이 곧 적용은 아니다. 사용자의 프리셋은 Preferences ▸ SheetForge ▸ Theme ▸ Colour preset에서 내장 DefaultHigh contrast 옆에 나타난다 — 오직 사용자의 선택만 적용된다. 표시 문자열은 사용자의 것이다(Core Loc 키 불필요).
  • 빈 id, 중복, 그리고 예약된 내장 id(default, highContrast)는 거부된다(Register가 예외를 던지며, PluginRegistrationConflict로 나타난다).
  • 테마가 재스타일링할 수 없는 것: 이 창들 안에 그려지는 네이티브 Unity 위젯(버튼 크롬, 필드 테두리)은 계속 에디터 스킨을 따른다 — 기능 및 제한 참고.

4.12 그래프 캔버스에서 편집하기 (선택)

데이터 스튜디오의 그래프는 그림이 아니라 작성 표면이다: 우클릭으로 레코드를 생성하고, 연결하고, 와이어 연결을 끊는다(데이터 스튜디오 참고). 이 모든 것은 평범한 RecordId@Tab 컬럼에 대해 플러그인 없는 프로젝트에서도 동작한다. 아래의 capability들은 코어가 닿을 수 없는 곳까지 이를 확장한다 — 그중 무엇도 기존 계약을 바꾸지 않으므로, 이들을 무시하는 플러그인도 변경 없이 컴파일된다.

capability는 어떻게 발견되는가 (먼저 읽을 것)

capability는 결코 스스로 발견되지 않는다. 창은 이미 등록된 객체를 캐스팅해서 이들 각각을 찾아낸다:

Capability캐스팅되는 대상추가하는 것
IAuthorableGraphShapeISheetForgeGraphPlugin이 등록한 캔버스 오버라이드새 레코드를 어디에 만들 수 있는지
IAuthorableEdgeContributorISheetForgeEdgePlugin이 등록한 엣지 기여자제스처 하나를 셀 쓰기 하나
IBatchAuthorableEdgeContributor동일한 엣지 기여자제스처 하나를 셀 쓰기 여러 개
IVirtualNodeFactory동일한 엣지 기여자노드 메뉴에 "하나 더 만들기" 제공
IEdgeSlotDeclarer동일한 엣지 기여자스키마가 유도할 수 없는 연결 슬롯 선언
IEdgeTokenEditor동일한 엣지 기여자토큰을 서술하고 키가 아닌 부분을 편집

그러므로 엣지 쪽 다섯 개의 capability는 그 클래스가 IEdgeContributor로 등록되었을 때만(ISheetForgeEdgePlugin을 통해, §4) 도달된다. 사용자의 도메인이 자신만의 엣지를 전혀 열지 않더라도, 그것이 등록을 건너뛸 이유는 아니다 — ContributeEdges를 빈 메서드로 구현하고 어쨌든 등록하라. 그 빈 기여자가 공식적으로 지원되는 합류 방법이다:

public sealed class ExampleSlotPlugin : ISheetForgeEdgePlugin
{
    public void RegisterEdgeContributors(EdgeContributorRegistry contributors)
        => contributors.Register(new ExampleSlotContributor());
}
 
public sealed class ExampleSlotContributor : IEdgeContributor, IEdgeSlotDeclarer
{
    public string Name => "ExampleSlots";
 
    // Nothing to declare — this class is here for the capabilities below.
    public void ContributeEdges(EdgeContributionContext context, ICollection<EdgeSpec> edges) { }
 
    public IReadOnlyList<DeclaredSlot> DeclareSlots(EdgeAuthoringContext context,
                                                    string nodeTab, string nodeRecordId) => …;
}

이들 모두 try/catch 안에서 실행된다: 예외는 영어 콘솔 경고가 되며 그 하나의 어포던스만 비활성화될 뿐, 다른 무엇도 영향받지 않는다.

새 레코드를 어디에 만들 수 있는가 — IAuthorableGraphShape

두 가지 기본값이 있으며, 이들은 의도적으로 다르다.

  • 생성 가능 탭 목록 — 이 capability가 대체하는 축이며, 캔버스가 애초에 열리는지도 결정한다 — 은 포커스 탭의 스키마가 도달할 수 있는 모든 탭을 참조를 전이적으로 따라가며 포함한다. 이는 데이터가 아니라 스키마로부터 계산되므로 아직 행이 없는 시트에서도 성립한다. 두 링크 깊이의 탭에 도달하는 것은 단계적이다 — 중간 레코드를 만들면 그 포트가 나타나고, 다음 홉이 계단식 목록에 합류한다.
  • 연결 계단식 목록 — 빈 캔버스에서 실제로 보게 되는 피커 — 은 더 좁다: 현재 화면에 그려진 포트들이 가리키는 탭에서 시작한다.

어느 쪽이든, 코드 레지스트리가 소유한 탭과 키 컬럼이 없는 탭은 제외된다 — 그곳의 새 레코드는 아이덴티티를 가질 수 없기 때문이다.

그 탭에 등록된 오버라이드(§4.7)는 이 인터페이스를 추가해 두 기본값을 모두 대체할 수 있다 — 그리고 화면의 어떤 포트도 받아들이지 않는다고 이름 붙인 탭은 사라지는 대신 사유가 붙은 채로 연결 계단식 목록에 남는다:

using SheetForge.Core.Graphing;
 
public sealed class ExamplePipelineAugmenter : IRecordCanvasAugmenter, IAuthorableGraphShape
{
    // Empty list = no creating from this canvas. The window still applies its own gates
    // (read-only source, running pipeline, workbook-backed tab, no key column) on top.
    public IReadOnlyList<string> CreatableTabs(GraphBuildContext context, string tabName)
        => new[] { "ExampleEffects", "ExampleActions" };
}

자신의 엣지를 편집 가능하게 만들기 — IAuthorableEdgeContributor

IEdgeContributor(§4)로 연 엣지는 그려지지만 편집할 수는 없다 — 그 엣지가 사는 표기법을 아는 것은 오직 사용자뿐이기 때문이다. 이 인터페이스를 추가하면 제스처를 다시 셀 텍스트로 되돌릴 수 있다. 창은 사용자가 반환한 것을 정확히 그대로 스테이징하며, 파서가 최종 판단자로 남는다:

using SheetForge.Core.Edges;
 
public sealed class ModifierStatEdgeContributor : IEdgeContributor, IAuthorableEdgeContributor
{
    public bool TryPlanConnect(EdgeAuthoringContext context, string fromTab, string fromRecordId,
                               string toTab, string toRecordId, out EdgeCellWrite write)
    {
        write = default;
        if (fromTab != "ExampleEffects" || toTab != "ExampleStats") return false;   // not mine
 
        // CellText = the cell as it reads right now (baseline + staging), not the parsed value.
        string current = context.CellText(fromTab, fromRecordId, "modifier");
        if (current.Contains(toRecordId + ":")) return false;                       // already linked
 
        string next = current.Length == 0 ? toRecordId + ":add:0"
                                          : current + "; " + toRecordId + ":add:0";
        write = new EdgeCellWrite(fromTab, fromRecordId, "modifier", next);
        return true;
    }
 
    public bool TryPlanDisconnect(EdgeAuthoringContext context, RecordEdge edge, out EdgeCellWrite write)
    {
        write = default;
        if (edge.FieldName != "modifier") return false;
        // …remove the fragment naming edge.ToRecordId, hand back the rewritten cell…
        write = new EdgeCellWrite(edge.FromTab, edge.FromRecordId, "modifier", rewritten);
        return true;
    }
}
  • false는 아무 일도 일어나지 않는다는 뜻이다. 스테이징이 생성되지 않으며 메뉴 항목은 정직한 사유와 함께 비활성화된다 — 결코 절반만 적용된 편집은 없다. 말이 안 되는 텍스트로 true를 반환하는 것도 허용되지만 무의미하다: 스테이징된 값은 직접 입력한 것과 동일한 사전 검증을 거치며 Problems에 나타난다.
  • 행이 아니라 키로 주소화한다. EdgeCellWrite는 (탭, 레코드 id, 필드)를 명명한다. 행 번호는 쓰기 시점에 다시 해석되므로, 스테이징된 계획은 행이 이동해도 살아남는다.
  • 사용자는 제스처 도중에 호출된다. 두 메서드 모두 try/catch 안에서 실행된다 — 예외는 영어 콘솔 경고가 되며 그 하나의 어포던스만 비활성화될 뿐, 다른 무엇도 영향받지 않는다.
  • 시트가 아니라 컨텍스트에 물어보라. CellText는 스테이징을 포함한 값을 반환하므로, 연달아 만든 두 링크는 서로를 본다. 대신 파싱된 테이블을 읽으면 첫 번째를 놓칠 것이다.

하나의 제스처로 여러 셀 바꾸기 — IBatchAuthorableEdgeContributor

일부 데이터는 하나의 항목을 병렬 컬럼에 나누어 담는다: stepDelays | stepTargets | stepCounts, 각 컬럼의 인덱스 i가 하나의 단계다. 그곳에 링크를 추가하려면 모든 컬럼을 한 번에 늘려야 한다 — 그렇지 않으면 컬럼들의 길이가 서로 달라진다 — 이는 단일 셀 계획으로는 피할 수 없는 절반만 유효한 상태다. 이 capability는 IAuthorableEdgeContributor형제다(하위 클래스가 아니다), 그래서 단수 형태만 가진 기여자는 영향받지 않는다:

using SheetForge.Core.Edges;
 
public sealed class ExampleStepContributor : IEdgeContributor, IBatchAuthorableEdgeContributor
{
    public bool TryPlanConnectMany(EdgeAuthoringContext context, string fromTab, string fromRecordId,
                                   string toTab, string toRecordId,
                                   out IReadOnlyList<EdgeCellWrite> writes)
    {
        writes = new[]
        {
            new EdgeCellWrite(fromTab, fromRecordId, "stepTargets", Append(context, fromTab, fromRecordId, toRecordId)),
            new EdgeCellWrite(fromTab, fromRecordId, "stepDelays",  AppendDefault(context, fromTab, fromRecordId)),
        };
        return true;
    }
 
    public bool TryPlanDisconnectMany(EdgeAuthoringContext context, RecordEdge edge,
                                      out IReadOnlyList<EdgeCellWrite> writes) => …;
}
  • 전부이거나 전무다. 목록 안의 모든 쓰기는 하나의 네이티브 Undo 단계로 스테이징된다. 하나라도 쓸 수 없으면(그런 행이 없거나, 읽기 전용 소스이거나, 파이프라인 실행 중) 아무것도 전혀 스테이징되지 않는다.
  • 배치 형태가 우선한다. 한 클래스가 단수 형태와 배치 형태를 둘 다 구현하면, 창은 배치 형태에만 묻는다 — 하나의 제스처가 서로 다른 두 개의 답을 갖는 일은 결코 없다. 기여자는 여전히 등록 순서대로 질의되며, 계획을 세우는 첫 번째 것이 우선한다.
  • 모든 쓰기는 주소가 필요하다. 빈 탭이나 필드를 가진 쓰기를 담은 목록(또는 빈 목록)은 "계획 없음"으로 취급된다.
  • 연결 해제는 사슬처럼 실행된다. 하나의 카드 위 여러 와이어가 단일 제스처로 잘릴 때, 사용자가 읽는 컨텍스트는 이 제스처 안의 이전 계획들을 이미 담고 있으므로, 같은 셀에서 토큰 두 개를 자르면 둘 다 제거된다. 단수 계약에는 그 중간값을 받을 표면이 없다 — 이 capability가 바로 그 한계를 없애는 방법이다.
  • 생성 중인 레코드는 대상이 될 수 없다. "한 제스처로 생성하고 연결하기" 흐름에서, 쓰기 주소는 새 행이 세션에 들어오기 전에 해석되므로, 생성 중인 레코드를 겨냥한 계획은 성립할 수 없고 전체 제스처가 정직하게 실패한다. 이미 존재하는 행을 겨냥하는 것(병렬 컬럼의 경우)은 영향받지 않는다.

무언가를 하나 더 만들기 — IVirtualNodeFactory

"하나 더"가 새로운 행이 아니라 여러 셀 각각에 하나씩 더해지는 요소일 때, 캔버스는 그 제스처를 스스로 지어낼 수 없다. 만들 수 있는 종류를 선언하고, 하나가 선택되면 셀 쓰기를 돌려준다:

using SheetForge.Core.Edges;
 
public sealed class ExampleStepContributor : IEdgeContributor, IVirtualNodeFactory
{
    // Called every time the node menu is built — keep it cheap and side-effect free.
    public IReadOnlyList<VirtualNodeKind> KindsFor(EdgeAuthoringContext context, string tab, string recordId)
        => tab == "ExampleSkills"
            ? new[] { new VirtualNodeKind("step", Loc("Add a step")) }   // your own translated string
            : null;
 
    public bool TryPlanCreate(EdgeAuthoringContext context, string tab, string recordId,
                              VirtualNodeKind kind, out IReadOnlyList<EdgeCellWrite> writes)
    {
        writes = null;
        if (kind.Id != "step") return false;                            // not mine → nothing happens
        writes = new[] { … };                                           // one element appended per column
        return true;
    }
}
  • 라벨은 이미 번역되어 있다. Core는 이를 번역하지 않는다 — 사용자의 팩이 해석한 문자열을 제공하라(§4.14 참고). 라벨 안의 /는 서브메뉴를 만드므로, 자신의 항목들을 그룹으로 묶을 수 있다.
  • tab은 가상 탭 이름이거나 비어 있을 수 있다. 사용자의 캔버스 오버라이드가 화면에 놓은 노드는 시트에 살지 않는다 — 메뉴는 사용자가 선언한 것을 여전히 제공한다. 사용자가 쓰는 셀은 노드의 아이덴티티가 아니라 사용자의 계획이 명명하기 때문이다. 코드 레지스트리가 소유한 탭은 제외된다.
  • 하나의 Undo 단계, 전부 아니면 전무 — 위의 배치 capability와 동일한 규칙이다. false는 아무것도 전혀 스테이징하지 않는다.

연결 슬롯 선언하기 — IEdgeSlotDeclarer

연결 슬롯은 보통 스키마(RecordId@Tab 컬럼)에서 온다. 사용자의 오버라이드가 화면에 놓은 노드에는 컬럼이 없으며, 기여자 엣지는 링크가 이미 존재해야만 슬롯을 드러낸다 — 그래서 링크는 시작할 곳이 없었다. 대신 슬롯을 선언하라:

using SheetForge.Core.Edges;
 
public sealed class ExampleStepContributor : IEdgeContributor, IEdgeSlotDeclarer, IBatchAuthorableEdgeContributor
{
    // Called per card and per port gate — keep it cheap and side-effect free.
    public IReadOnlyList<DeclaredSlot> DeclareSlots(EdgeAuthoringContext context,
                                                    string nodeTab, string nodeRecordId)
        => nodeTab == "#step"
            ? new[] { new DeclaredSlot("target", "ExampleEffects", /*isList*/ false) }
            : null;
}
  • 이름은 두 가지 역할을 한다. 그 노드 안에서 고유해야 하며, 그 안으로 그리는 엣지의 FieldName과 같아야 한다 — 슬롯 조회와 와이어 앵커링 둘 다 그 이름으로 매칭된다. 시트 컬럼이 이미 그 이름을 가지고 있다면, 시트가 이기고 사용자의 선언은 조용히 버려진다.
  • 선언은 계획이 아니다. 선언된 슬롯은 사용자의 계획(IAuthorableEdgeContributor 또는 배치 형태)을 통해 연결된다. 계획 없이 선언만 하면 포트는 열리지만 아무것도 스테이징되지 않는다 — 둘 다 구현하라.
  • 포트는 시트 행이 없는 노드에도 열린다. 탭이 시트가 아닌 노드에 대해서는, 창이 그 이름으로 행을 찾지 않는다 — 쓰기 주소는 사용자의 계획에서 나오며 스테이징 시점에 확인된다.

토큰이 말하는 것 편집하기 — IEdgeTokenEditor

연결과 연결 해제는 토큰 전체를 움직인다. 토큰은 종종 키 이상이다: attack:add:10은 스탯 그리고 그 양을 명명한다. 동일한 기여자에 이 capability를 추가하면 와이어 인스펙터는 그 나머지 부분 — 키가 아닌 부분 — 을 위한 행을 하나 얻는다:

using SheetForge.Core.Edges;
 
public sealed class ModifierStatEdgeContributor : IEdgeContributor, IAuthorableEdgeContributor, IEdgeTokenEditor
{
    public bool TryDescribeToken(EdgeAuthoringContext context, RecordEdge edge,
                                 out EdgeTokenDescription description)
    {
        description = null;
        if (edge.FieldName != "modifier") return false;                    // not mine
 
        // Read the fragment out of the cell — never rebuild it from the edge, or the
        // highlight points at a piece that is not there.
        string fragment = FindFragment(context.CellText(edge.FromTab, edge.FromRecordId, "modifier"),
                                       edge.ToRecordId);
        if (fragment == null) return false;                                // hand-edited away
 
        description = new EdgeTokenDescription(
            /*tokenText*/ fragment,                                        // "attack:add:10"
            /*modifierText*/ fragment.Substring(fragment.IndexOf(':') + 1),// "add:10"
            /*modifierLabel*/ "op:value",
            /*isChoice*/ false, /*options*/ null, /*optionLabels*/ null);   // free text
        return true;
    }
 
    public bool TryPlanSetModifier(EdgeAuthoringContext context, RecordEdge edge,
                                   string newModifier, out EdgeCellWrite write)
    {
        // …rebuild the cell with that one fragment's leftover replaced, key untouched…
    }
}
  • 양쪽 절반 모두 같은 셀을 읽는다. 엣지는 자신이 무엇을 가리키는지는 알지만 오늘 어떤 글자로 쓰여 있는지는 모른다 — 그래서 서술하는 쪽도 쓰는 쪽과 동일한 EdgeAuthoringContext를 받는다. 이것이 강조 표시된 조각과 재작성된 조각이 증명 가능하게 동일한 것이도록 만든다.
  • 키는 결코 이 문을 통해 이동하지 않는다. 링크가 가리키는 대상을 바꾸는 것은 재조준이다(와이어를 드래그). 이 행은 나머지 부분만 바꾼다. 둘 중 어느 절반에서든 false를 반환하면 행이 숨겨지거나 정직하게 비활성화된다 — 스테이징도, 조용한 실패도 없다.
  • 위젯을 서술하는 것은 사용자의 몫이다. options와 함께 isChoice를 쓰면 팝업이 그려지고, 아니면 텍스트 필드가 그려진다 — 행의 라벨과 옵션 라벨은 사용자의 문자열이다. 나머지가 전혀 없다면 new EdgeTokenDescription(tokenText)를 구성하면 행이 그려지지 않는다 — 코어 참조(그 키가 토큰 전체인)는 코드 없이도 이렇게 동작한다.

자신의 와이어를 조금이라도 편집 가능하게 만들기

와이어는 자신이 기록되는 셀을 명명할 때만 편집할 수 있다. 코어는 자신이 직접 읽는 참조에 대해서는 이를 채워 넣는다. 사용자가 추가한 추가 엣지(§4.7)는 필드를 명명해서 이를 한다:

// Display-only edge — the canvas honestly reports it cannot be edited.
builder.AddEdge(tab, recordId, targetTab, targetKey, "raises");
 
// Edge that names its cell: "this link is written in (tab, record, column)".
builder.AddEdge(tab, recordId, targetTab, targetKey, "listen", /*fieldName*/ "listen");

셀을 명명한다고 편집 가능하다고 약속하는 것은 아니다 — 링크가 어디에 사는지를 말할 뿐이다. 사용자가 추가한 엣지는 기여자 엣지가 쓰는 것과 동일한 배관으로 넘겨지므로, IAuthorableEdgeContributor가 이를 주장할 때 정확히 그때 편집 가능해진다. 그 컬럼이 재작성할 사람이 없는 평범한 텍스트나 enum 컬럼이라면, 캔버스는 와이어가 여기서는 편집 가능하지 않다고 보고한다 — 조용한 무동작이 아니라 진실을 말하는 것이다.

4.13 커스텀 셀 위젯 (선택, Editor 어셈블리)

그리드는 모든 셀을 내장 위젯(불리언 토글, enum 팝업, 참조 피커, 원문 텍스트)으로 그린다. 어떤 타입이 더 나은 입력을 받을 자격이 있을 때 — 커브, 색상, 미니 문법 조합기, 여러 줄 박스 — 값이 어떻게 파싱되는지는 건드리지 않고 그 타입 이름에 대한 위젯만 교체한다:

using SheetForge.Editor.Studio;
using UnityEngine.UIElements;
 
public sealed class ModifierCellEditor : IStudioCellEditorProvider
{
    // The base type name from @type (a CellParserRegistry name; for a wrapper, the wrapper name).
    public string TypeName => "Modifier";
 
    public VisualElement CreateEditor(StudioCellEditorContext context)
    {
        if (context.Type.IsList) return null;   // decline — the built-in widget takes this cell.
 
        var field = new TextField { value = context.CurrentRawText };
        // Typing burst: coalesced into ONE Undo step for this cell.
        field.RegisterValueChangedCallback(e => context.CommitTyping(e.newValue));
        // Discrete confirmation (focus out): its own Undo step.
        field.RegisterCallback<FocusOutEvent>(_ => context.Commit(field.value));
        return field;
    }
}
  • 위젯은 입력의 형태를 만들 뿐, 의미는 파서가 소유한다. 무엇을 커밋하든 정본 시트 텍스트다 — 입력한 값과 동일한 사전 검증을 거치며, 문제는 Problems 패널에 나타난다. 위젯은 결코 검증할 필요가 없다.
  • 의도적으로 두 개의 커밋 표면이 있다. Commit(목록에서 선택, 슬라이더 놓기, 포커스 아웃)은 하나의 Undo 단계를 만든다. CommitTyping(키 입력마다)은 연속 입력을 하나의 단계로 합친다. 이 둘을 하나의 호출로 합치면 글자마다 Undo 단계가 흩뿌려지거나, 서로 다른 두 선택이 합쳐질 것이다.
  • null을 반환하면 그 셀을 거부하는 것이며 내장 위젯이 대신 맡는다 — 사용자가 처리하지 않는 형태(사용자 타입의 List<T>, 선택적 필드)에 대한 정직한 답이다. context.Type(파싱된 @type 토큰)이 판단에 필요한 모든 것을 담고 있다.
  • **ReferenceKeys(tab)**은 내장 참조 피커가 사용하는 것과 동일한 후보 목록(프로젝션된 키 ∪ 코드 레지스트리 키 ∪ 스테이징된 신규 행 키, 정렬됨)을 건네준다 — 직접 모을 필요가 없다. 사람이 내장 셀이 여는 것과 동일한 드롭다운에서 그 목록을 선택하게 하려면, StudioKeyPicker.Show(screenAnchor, tab, candidates, picked)를 호출하고 반환된 키를 커밋하기 전에 자신의 표기법에 끼워 넣는다. (레코드 생성, 셀을 비워두기, 리스트 다중 토글은 내장 참조 셀 자신의 규칙이며 이 파사드에는 없다 — 셀 텍스트 전체를 소유하는 위젯은 그 결정들도 함께 소유한다.)
  • 자신의 것뿐 아니라 내장 타입 이름도 주장할 수 있다. 등록된 위젯 분기가 먼저 실행되므로, TypeName => "float"는 정말로 모든 float 컬럼의 원문 텍스트 박스를 대체한다 — 슬라이더, 퍼센트 필드, 단위가 붙은 박스가 들어오는 방법이 바로 이것이다. 여기에는 두 가지 주의가 따른다:
    • 프로젝트 안의 그 타입을 가진 모든 컬럼에 적용되므로, context.FieldName / context.Tab을 읽어 의도하지 않은 컬럼에는 null을 반환해 범위를 제한하라.
    • 커밋하는 것은 여전히 정본 시트 텍스트이므로, 슬라이더는 파서가 다시 읽어들이는 방식대로 값을 렌더링해야 한다(라운드트립이 기대하는 float 표기는 CanonicalValueRenderer.RenderFloat를 참고하라).
  • 충돌은 경고하고, 발견은 자동이다. 다른 모든 계약과 동일한 TypeCache 발견이다 — 두 프로바이더가 하나의 타입 이름을 주장하면 먼저 발견된 것이 이기며 콘솔 경고가 둘 다의 이름을 알려준다. CreateEditor가 던진 예외는 잡혀서 경고로 남고, 셀은 내장 위젯으로 대체된다.
  • 위젯을 작성하기 전에, 힌트로 충분한지 먼저 확인하라. 드롭다운, 여러 줄 박스, 슬라이더, 토글, 색상 피커, 커브 에디터, 그라디언트 에디터가 원하는 전부라면, 대신 StudioCellEditorHint를 등록한다(§4.16) — 위젯 코드가 필요 없고, 브라우저에서도 동작한다. 순서는 이렇다: 이 계약이 먼저, 그다음 힌트, 그다음 코어 기본값 — 그래서 어떤 위젯도 그 타입을 주장하지 않았거나 주장했던 위젯이 거부했을 때 셀이 받는 것이 바로 힌트다.

4.14 플러그인 UI 문자열 (선택)

사용자 팩이 보여주는 라벨 — 인스펙터 액션, 위젯 캡션, §4.16의 선언적 표면 — 은 사용자의 언어를 따를 수 있다. 언어 키별로 문장을 등록하라. Loc.Tr는 제품 테이블보다 먼저 이 오버레이를 참조하며, 브라우저의 t()도 마찬가지다:

using System.Collections.Generic;
using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
 
public sealed class ExampleLocStrings : ISheetForgeStringsPlugin
{
    // Prefix keys with your pack name so packs never collide.
    public const string BrakeActionKey = "plugin.skillsDemo.action.setBrake";
 
    public void RegisterStrings(StringOverlayRegistry strings)
    {
        strings.Register(BrakeActionKey, new Dictionary<string, string>
        {
            { "en", "Set reaction brake to 0.25s" },
            { "ko", "반응 제동을 0.25초로 넣기" },
        });
        // Or one language at a time: strings.Register(key, "en", "…");
    }
}
  • 이 계약은 Core에 있으므로, 메인 어셈블리에 둔다. 두 호스트 모두 사용자 팩의 라벨을 보여주며, 브라우저는 메인 DLL만 로드하므로 — 문자열 플러그인이 에디터 컴패니언 어셈블리에 있으면 웹 앱은 원문 키를 그대로 보여주게 된다.
  • 등록은 선택이다. 등록되지 않은 키는 계속 그대로 표시된다 — 이 계약은 필수가 아니라 업그레이드 경로다.
  • 언어는 IETF 코드다("en", "ko", "zh-Hans", "pt-BR" 등), 대소문자 구분 없이 매칭된다.
    • 최소한 영어는 등록하라: 조회는 요청 언어 → 영어 → 실패 순으로 대체되므로, 다른 어떤 언어의 사용자든 원문 키 대신 사용자의 영어 문장을 읽는다.
    • 제품이 알지 못하는 코드는 영어로 접어드는 대신 사유와 함께 거부된다 — 오타가 조용히 영어가 되어버리면 추적할 수 없기 때문이다.
  • 제품 키는 오버라이드할 수 없다 — 내장 키를 명명하는 등록은 거부되므로, 오버레이가 UI를 제품 자신의 문장과 어긋나게 만들 수는 결코 없다. 특히 메뉴 라벨은 언어 테이블에서 직접 베이크되므로, 이를 재작성할 수 있는 오버레이는 안내 문구와 실제 메뉴 경로를 서로 어긋나게 만들 것이다. 오버레이는 새로운 키를 위한 것이다.
  • 여러 팩에 걸친 중복 등록은 먼저 발견된 것을 유지하며, 그 사유가 함께 기록된다 — 마지막 등록이 조용히 이긴다면 화면이 플러그인 설치 순서에 따라 달라질 것이다.
  • 빈 키와 빈 값도 거부된다. 모든 거부는 개발자용 영어 문장이다 — 그 대상 독자가 최종 사용자가 아니라 플러그인 제작자이기 때문이다.
  • 제품의 10개 언어 패리티 규칙은 건드려지지 않는다: 사용자의 문자열은 코어 테이블 옆의 조회용 오버레이에 살 뿐, 결코 그 안에 있지 않다.

(데모는 이를 Assets/SheetForge.PluginDemo/ExampleLocStrings.cs에 담아 제공한다 — 위의 이유로 메인 어셈블리에 있다 — 그 인스펙터 액션(§4.10)과 선언적 표면(§4.16)이 표시하는 라벨을 등록한다.)

4.15 하나의 셀 안에 담는 여러 줄 텍스트 (대사, 설명, 스크립트)

진짜 개행 문자는 결코 셀 안에 살 수 없다. 파이프라인의 입력은 TSV이며, 여기서 탭은 셀을 구분하고 개행은 행을 구분한다 — 그래서 둘 중 어느 문자든 담은 셀은 아예 표현될 방법이 없다.

모든 소스는 손상된 그리드를 통과시키는 대신 입구에서 이를 강제한다:

  • CSV와 xlsx 리더는 셀의 좌표와 함께 UnsupportedCellCharacter를 보고한다(첫 번째뿐 아니라 문제가 된 모든 셀을 수집한다);
  • Google fetch도 마찬가지다;
  • 그리고 .tsv 파일에서는 그 문자가 이미 행 구분자였다.

이는 메워야 할 공백이 아니라 형식의 설계 상수다 — 그래서 긴 텍스트를 다루는 도메인은 전적으로 플러그인 영역 안에 있는 세 부분짜리 관례를 통해 이와 함께 동작한다.

1. 이스케이프를 정하고 파서에 작성하라. 관례적인 선택은 시트에 두 글자로 된 리터럴 \n을 쓰고, 들어올 때 언이스케이프하고 나갈 때 다시 이스케이프하는 것이다:

public sealed class ProseCellParser : ICellValueParser, ICustomCellType
{
    public string TypeName => "Prose";
    public Type ValueType => typeof(string);
 
    public bool TryParse(CellParseContext ctx, string text, out object value)
    {
        value = text.Replace("\\n", "\n");     // sheet spelling → the value your game sees
        return true;
    }
 
    public bool TryRender(object value, out string text, out string reason)
    {
        reason = null;
        text = ((string)value).Replace("\r\n", "\n").Replace("\n", "\\n");   // the exact reverse
        return true;
    }
}

두 방향을 정확한 역함수로 만들고 이를 증명하라. TryRender는 Export와 Push가 다시 기록하는 것이므로, 이것이 TryParse를 글자 단위로 되돌리지 못하면 "시트 → 임포트 → export → 시트" 라운드트립이 아무도 편집하지 않은 텍스트를 재작성하게 된다. 나갈 때 \r\n\n으로 정규화하는 것(위처럼)이 Windows에서 작성된 값이 연속된 export마다 두 표기법 사이를 오가지 않도록 지켜준다. 파싱된 값을 렌더링해 원본 셀 텍스트와 비교하는 테스트 하나면 이를 고정하기에 충분하다.

2. 셀에 진짜 에디터를 부여하라. \n으로 이스케이프된 값은 한 줄짜리 박스에 타이핑하기 불쾌하다 — 이것이 정확히 §4.13이 존재하는 이유다. "Prose"에 대해 여러 줄 TextField(multiline = true)를 반환하는 IStudioCellEditorProvider를 등록해, 진짜 개행으로 값을 보여주고 다시 이스케이프해서 커밋하라. 키 입력마다가 아니라 포커스 아웃 시 Commit으로 커밋한다(편집 세션당 하나의 undo 단계).

3. 이 관례가 닿지 않는 한 곳을 알아두라. 누군가 Google 시트에서 직접 Alt+Enter를 입력하면 라이브 셀에 진짜 개행이 생기며, 그 셀은 다음 fetch에서 그 위치를 가리키는 좌표와 함께 거부된다. 그 거부는 정직하고 고칠 수 있지만, 어쨌든 거부다 — 그러니 팀의 작가들이 스프레드시트 자체에서 글을 쓴다면, 긴 텍스트는 \n으로 쓴다고 사용자 자신의 문서에 밝혀 두거나, 이스케이프가 대신 처리되는 2단계의 데이터 스튜디오 셀 위젯에서 쓰게 하라.

4.16 선언적 작성 표면 (선택)

§4.9, §4.10, §4.13은 VisualElement를 반환하며, 이것이 정확히 이들이 에디터 전용인 이유다: 브라우저는 UIToolkit 타입을 로드할 수 없으므로, 그런 방식으로 작성된 확장은 한쪽 화면에만 존재하고 다른 쪽에는 존재하지 않는다.

이 계약은 동일한 필요를 데이터로서 답한다. 사용자는 껍데기 — id, 라벨 키, 배치, 톤 — 를 서술하고, *조건(predicate)*과 효과만을 델리게이트로 제공한다. 이렇게 하나 등록하면 에디터의 UIToolkit 렌더러와 브라우저의 React 렌더러 양쪽에서 똑같이 그려진다.

using SheetForge.Core.Plugins;
using SheetForge.Core.Studio;
using SheetForge.Core.Theming;   // ThemeColorSlot — tones are slots, never hard-coded colours
 
public sealed class ExampleStudioUi : ISheetForgeStudioPlugin
{
    public void RegisterStudioUi(StudioUiRegistry ui)
    {
        // ① A verb — right-click a row, and this appears at the end of the menu.
        ui.AddAction(new StudioActionDescriptor(
            "skillsDemo.setBrake",                        // unique id ("pack.verb" reads well)
            ExampleLocStrings.BrakeActionKey,             // a Loc key (§4.14); unregistered = shown verbatim
            StudioActionPlacement.RowContextMenu,
            ctx => ctx.Tab == "ExampleActions" && !string.IsNullOrEmpty(ctx.RecordId),   // cheap predicate
            ctx => ctx.StageCell(ctx.Tab, ctx.RecordId, "brakeSeconds", "0.25")));
 
        // ② A summary panel — a node tree, rebuilt each recompute tick.
        ui.AddPanel(new StudioPanelDescriptor("skillsDemo.summary", ExampleLocStrings.PanelTitleKey, ctx =>
            StudioUiNode.List(
                StudioUiNode.Heading("Cast summary"),
                StudioUiNode.KeyValue("Total damage", TotalDamage(ctx).ToString()),
                StudioUiNode.Progress("Cast time", CastRatio(ctx), ThemeColorSlot.Accent),
                StudioUiNode.Button("Fill every unbraked reaction", "skillsDemo.fillBrakes"))));
 
        // ③ A column badge — one node beside a column header (null = nothing on that column).
        ui.AddColumnBadge(new StudioColumnBadgeDescriptor((ctx, tab, field) =>
            field == "brakeSeconds" ? StudioUiNode.Badge(UnbrakedCount(ctx) + " unbraked", ThemeColorSlot.Warning) : null));
 
        // ④ A cell-editor hint — pick a built-in widget for your type without writing one.
        ui.AddCellEditorHint(new StudioCellEditorHint("Modifier", StudioCellEditorArchetype.Dropdown, Options));
    }
}

어휘는 의도적으로 한계가 있다 — 오직 덧붙여서만 자라나며 결코 중간에 끼워 넣지 않으므로, 기존 등록은 그 의미를 그대로 유지한다.

  • 액션을 위한 다섯 가지 배치: Inspector, RowContextMenu, TopbarMenu, ColumnHeaderMenu, CanvasNodeMenu.
    • 각각은 그 자리가 아는 것으로 컨텍스트를 채운다 — 행 배치는 레코드를, 컬럼 배치는 컬럼 이름을, 캔버스 배치는 그 노드의 레코드를 담으며 — 나머지는 비워 둔다. 그러니 어떤 자리가 제공하지 않는 필드를 읽기 전에 방어 코드를 두라.
  • 패널이나 배지를 위한 열세 가지 노드 종류: Row, Label, Chip, Badge, Button, Rule, Heading, KeyValue, Table, List, Progress, Input, Link.
    • 이들은 정적 팩토리(StudioUiNode.Label(…), .WithTooltip(…))를 통해 만들어지므로, 노드는 불변이며 그 종류에 의미 있는 필드만 채워진다.
  • 일곱 가지 셀 에디터 아키타입: Dropdown(후보를 직접 제공), MultilineText, Slider(범위를 직접 제공), Toggle(두 개의 정본 텍스트를 직접 제공), ColorPicker(#RRGGBB / #RRGGBBAA), CurveEditorGradientEditor(셀 텍스트는 시트 문법의 정규 커브/그라디언트 표기다 — 예를 들어 CurveValue.Render()를 통해 그 표기법을 쓰는 자신만의 타입을 가진 팩이 이를 선언할 수 있다). 내장 Color, AnimationCurve, Gradient 타입도 정확히 같은 메커니즘으로 연결되어 있다 — BuiltinCellEditorHints가 이 세 힌트를 담고 있다 — 그리고 호스트는 팩의 등록을 먼저 참조하므로, 그 타입 이름들 중 하나로 힌트를 등록하면 거부되는 대신 내장 선택을 오버라이드한다. 에디터에서는 마지막 세 아키타입이 Unity의 색상, 커브, 그라디언트 필드다; 브라우저에서는 앱 자신의 에디터다; 이들 중 하나를 담은 타입의 List<>는 양쪽 모두에서 칩 에디터가 된다. Plugin Demo의 Falloff 타입이 정확히 이렇게 한다: 그 파서는 CurveValue.TryParse로 셀을 읽으며, 힌트 등록 하나로 Unity에서는 커브 필드를, 브라우저에서는 커브 에디터를 얻는다.
  • 레이아웃 수치는 어디에도 없다. 픽셀과 비율은 한쪽 화면의 결을 다른 쪽으로 새어 나가게 할 것이다 — 사용자는 무엇을 보여줄지만 말하고, 어떻게 배치할지는 각 렌더러가 결정한다.

작성하기 전에 알아 둘 규칙:

  • 변형(mutation)은 사용자의 손이 지나가는 것과 같은 문을 통과한다. StudioSurfaceContext는 읽기 전용인 Tables / References / CodeRegistries 위에, 액션에 정확히 네 가지 힘만 준다 — StageCell, StageCells(여러 셀, 하나의 Undo 단계, 전부 아니면 전무), FocusRecord, RequestRebuild.
    • 그래서 플러그인의 동사(verb)는 평범한 스테이징된 편집이다: 하나의 Ctrl+Z 단계이며, push하기 전까지는 아무것도 시트에 닿지 않고, 동일한 사전 검증을 거친다.
    • 스테이징 게이트도 그대로 적용된다 — 읽기 전용 소스, 실행 중인 파이프라인, 워크북 기반 탭은 표시된 사유와 함께 이를 차단한다.
  • 조건(predicate)은 끊임없이 실행된다. AppliesTo, 패널 구축, 배지 제공은 모든 제스처와 모든 재계산 틱마다 실행된다. 건네받은 스냅샷을 읽어라 — IO도, 네트워크도, 긴 연산도 없어야 한다.
  • 표시되었다고 실행되는 것은 아니다. 호스트는 호출 시점에 조건을 다시 확인한다. 메뉴가 그려진 이후 상황이 바뀌었다면, 답은 두 번째 실패가 아니라 정직한 무동작과 다시 그리기다. 브라우저도 오래된 id에 대해 똑같이 동작한다.
  • ConfirmKey는 먼저 묻는다. 액션에 확인 키를 주면 호스트는 이를 실행하기 전에 그 문장을 보여준다 — 한 번에 여러 셀을 스테이징하는 동사에 알맞은 동작이다.
  • Link 노드는 http/https만 연다. 이 규칙은 두 호스트 모두가 묻는 하나의 Core 조건(StudioUiNode.IsAllowedUrl)이므로, 무엇이 열기에 안전한지에 대해 서로 다르게 판단할 수 없다. 브라우저는 앵커를 렌더링하기 전에 동일한 형태를 다시 확인하며, 이는 더 거부할 수는 있어도 결코 덜 거부할 수는 없다.
    • url은 등록 시점에 걸러지는 대신 사용자가 쓴 그대로 저장되며, 여는 시점에 사유와 함께 거부된다 — 이를 작성한 팩이 왜 아무 일도 일어나지 않았는지 알아낼 수 있어야 하기 때문이다.
  • 패널은 상태를 갖지 않는다. 매 틱마다 다시 만들어지며, 값이 있어야 할 유일한 곳은 시트다(스테이징됨). 아무것도 패널을 등록하지 않으면 그 패널은 아예 그려지지 않는다.
  • 예외는 격리된다 — throw는 영어 콘솔 경고가 되고 그 하나의 어포던스만 사라질 뿐, 창은 사라지지 않는다.

서술만으로 부족할 때 — IStudioPanelProvider (Editor 어셈블리)

임의의 렌더링, 복합 입력, 다단계 흐름에는 여기 어휘가 없으며, 이를 새로 만드는 것은 영원히 소형 UI 프레임워크를 유지보수하는 셈이 될 것이다. 그래서 이 한계는 의도적이며 탈출구는 넓게 열려 있다: 에디터 컴패니언 어셈블리에 IStudioPanelProvider를 구현하고 원하는 대로 그린다.

using SheetForge.Editor.Studio;
using UnityEngine.UIElements;
 
public sealed class ExampleStudioPanel : IStudioPanelProvider
{
    public string Id => "skillsDemo.summary";        // same id as the descriptive panel above
    public string TitleKey => ExampleLocStrings.PanelTitleKey;
    public bool AppliesTo(StudioSurfaceContext context) => context.Tab == "ExampleSkills";
    public VisualElement CreatePanel(StudioSurfaceContext context) => new Label("…anything…");
}

둘 다 같은 Id로 등록하면 각 호스트는 자신이 그릴 수 있는 것을 취한다: 에디터는 리치 쪽을 쓰고, 브라우저는 서술형 쪽을 쓴다. 이것이 두 번째 계약 집합 없이도 "브라우저가 닿는 데까지는, 에디터에서는 끝까지"가 성립하는 방법이다.

웹 전용 변형은 없다 — 리치 패널이 없다는 것은 서술형이 대신 그려진다는 뜻이지, 패널이 사라진다는 뜻이 아니다. 이 엘리먼트도 한 번의 재계산 틱만 살아 있으므로, 마찬가지로 상태를 갖지 않는다.

4.17 파이프라인 관찰하기 (선택)

제품 간 브리지, 도메인 텔레메트리, 후속 생성기는 종종 재파싱하지 않고도 임포트가 무엇을 만들어냈는지 알아야 한다. IPipelineObserver를 구현하고 ISheetForgePipelinePlugin을 통해 등록한다:

using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
 
public sealed class ExampleImportObserver : IPipelineObserver, ISheetForgePipelinePlugin
{
    public void RegisterPipelineObservers(PipelineObserverRegistry observers) => observers.Register(this);
 
    public void OnImportCompleted(PipelineRunView view)
    {
        // view = Success · Tables · Diagnostics · SkippedTabs · EnumTabs — an immutable snapshot.
        if (!view.Success) return;
        // … cache what you need; do not hold the tables ...
    }
}
  • 관찰은 결과를 바꿀 수 없다. 사용자는 하나의 불변 스냅샷을 받고 아무것도 반환하지 않는다. 값을 바꾸거나 진단을 추가하는 훅은 의도적으로 없다: 값을 해석하는 것은 셀 타입(§2)의 몫이고 규칙 위반을 보고하는 것은 도메인 검증기(§3)의 몫이다. 참여를 관찰 계약에 섞어 넣으면 "관찰자는 결과를 바꿀 수 없다"가 실제로는 거짓이 되어 버릴 것이다.
  • 명시적 임포트 사이클당 한 번, 그 끝에서, 성공했든 실패했든. 스테이징하는 동안 재계산되는 사전 검증 프로젝션에서는 실행되지 않는다 — 어떤 제삼자 코드도 키 입력 빈도에 매달리지 않는다.
  • 실패한 실행도 파싱한 것은 그대로 보고한다. Tables는 검증이 실패하기 전에 파싱된 탭들을 담으며, 이는 격리(quarantine) 흐름이 사용하는 것과 동일한 재료다(데이터 스튜디오) — 그래서 관찰자는 실패한 실행에 대해 아무것도 아닌 것 대신 진실한 그림을 본다.
  • 정직한 두 가지 공백. 관찰자는 임포트 사이클 자신의 완료 지점에서 발생하므로, 그곳에 결코 도달하지 못하는 실행은 아예 발생하지 않는다.
    • 파이프라인이 실행되기 전에 중단된 임포트(활성 설정 없음, Addressables 게이트 거부).
    • codegen→컴파일 단계가 컴파일 에러로 중단되는 경우.
    • 이는 거짓 발생이 아니라 무발생이다: "임포트가 시도되었다"는 사실이 필요하다면, 이를 에디터 측 ImportEvents 버스와 함께 사용하라.
  • throw는 그 관찰자에게만 격리되며, 사유가 수집된다. 임포트의 출력은 조금도 바뀌지 않는다.
  • 향후의 관찰 지점(파싱 직후, export 사이클)은 등록된 관찰자를 캐스팅해 발견되는 형제 capability 인터페이스로 도착할 것이므로, 새 지점을 추가해도 오늘 작성된 구현이 깨지지는 않는다.

5. 커스텀 임포트 소스 (ISheetSourceProvider)

새로운 소스(데이터베이스, REST 엔드포인트, 자체 형식)는 Core/Editor 수정 없이 합류한다. Editor 어셈블리에 ISheetSourceProvider를 구현한다. SourceProviderRegistryTypeCache를 통해 이를 발견하며, 설정의 "Source" 드롭다운에 내장 소스들과 나란히 나타난다. 프로바이더가 답해야 하는 네 가지:

  1. FetchCreateTabSource(settings)는 탭 이름 → 원본 TSV 텍스트를 제공하는 ITabSource를 반환한다(비동기, 환경 문제는 예외가 아니라 진단 정보, 부분 출력 허용).
  2. 쓰기 반영CreateReflectTarget(dispatcher, settings)는 작성 디스패처에 연결되는 ISourceReflectTarget을 반환한다(대상을 조립하려면 디스패처의 공개 Session / Callbacks / Baselines를 사용). 소스가 기록 가능한 경우에만 대상을 반환한다.
  3. 가시성GetVisibility(settings)는 인스펙터가 어떤 설정 필드를 보여줘야 하는지 반환한다.
  4. CanAuthor — 읽기 전용 소스에는 false를 반환한다. 작성 창들은 자신의 편집 UI를 비활성화한다(Google ExportUrl과 동일).

안정적인 문자열 IdsourceProviderId에 저장된다. 내장 소스는 "LocalFile" / "GoogleSheet"를 자신의 Id로 사용한다. sourceProviderId가 비어 있으면 내장 LocalFile 기본값으로 해석된다. Id가 비어 있으면 해당 프로바이더는 UI에서 제외된다(테스트 프로브에 유용하다).

프로바이더는 의도적으로 Editor 어셈블리에 존재한다 — 소스는 IO 경계이며, IO를 Core 밖에 두는 것이 그 순수성을 지킨다(다른 세 계약은 순수한 Core다).

5.5 자동화와 통합을 위한 공개 도구

등록 계약을 넘어서, SheetForge를 확장하는 대신 구동하는 코드를 위한 다섯 개의 공개 진입점이 있다 — CI 스크립트, 빌드 훅, 자신만의 인스펙터 버튼, 또는 동일한 시트로부터 자신만의 에셋을 베이크하는 두 번째 제품.

한 사이클 실행하기SheetForge.Editor.Pipeline.SheetForgeActions:

SheetForgeActions.RunImport();        // exactly what the toolbar's "Pull from source" does
SheetForgeActions.RunExport();
SheetForgeActions.RunPush();
SheetForgeActions.RunHealthCheck();

각 호출은 사이클 전체다: 설정 해석, Addressables 게이트, 상호 배제, 확인과 승인 모달, 진행 바, 그리고 도메인 리로드에 걸친 코드젠→컴파일→베이크 재개까지. 조립할 절반짜리 사이클은 없으므로 실수로 건너뛸 게이트도 없다.

알아둘 것 두 가지:

  • RunImportRunPush던지고 잊는(fire-and-forget) 방식이다 — 에디터 메인 스레드가 네트워크 IO에 블록되어서는 안 되므로 본문이 async void다. 그래서 반환은 완료를 의미하지 않는다 — 이를 위해서는 ImportEvents.ImportCompleted를 구독하라.
  • Push는 여전히 자신의 승인 모달을 보여주므로, 지켜보는 사람 없이 스크립트가 전송할 수는 없다.

내장 소스가 잡는 것과 동일한 락을 잡기 — 자신만의 백엔드에 기록하는 커스텀 소스 프로바이더를 위해:

if (!SheetForgeActions.TryBeginExclusiveScope(out IDisposable scope)) return;   // something is running
using (scope) { /* write to your source */ }        // Dispose releases; a second Dispose is harmless
// schedule any re-import AFTER the scope closes — the lock is not re-entrant

SheetForgeActions.IsBusy는 아무것도 잡지 않고 동일한 질문에 답한다. 락 자체는 의도적으로 internal로 남아 있다: 만약 공개되어 있다면, 그 End()를 호출하는 것이 다른 누군가의 실행을 해제할 수 있다 — scope는 이를 불가능하게 만든다. 오직 그것을 쥔 쪽만 해제할 수 있기 때문이다.

내장 소스가 마무리하는 방식 그대로 쓰기 반영을 마무리하기AuthoringDispatcher.FinalizeReflectSuccess(writtenTabs)ISourceReflectTarget이 도달해야 하는 마무리를 실행한다:

  • 기록한 탭에 대한 retain 정리;
  • ClearUndo 확정 경계;
  • 그리고 자동 재임포트.

내장 로컬과 Google 경로는 동일한 본문을 실행하므로, 사용자의 프로바이더도 이를 근사하는 대신 동일하게 끝난다. 그 옆의 BuildProjectedTabs()는 프로젝션을 탭별 TSV로 건네준다 — 사용자가 막 전송하려는 것 — 그래서 프로바이더는 기록하지 않고도 이를 미리보거나 변환할 수 있다. 빈 writtenTabs 목록은 스테이징을 그대로 유지하는 무동작이다.

사용자 자신의 UI에 제품 자신의 문장을 보여주기ImportReportText.Render(report)(Core.Tooling)는 콘솔에 아무것도 기록하지 않은 채 사람이 읽기 쉬운 리포트를 문자열로 반환한다. SheetForgeActions.RenderReportText(report)는 사용자의 현재 에디터 언어로 된 동일한 것이다. AuthoringDispatchCallbacks.RenderReport와 함께 사용하면, 두 번째 작성 표면도 제품이 사용하는 것과 정확히 같은 표현으로 실패를 보고한다.

생성된 타입을 몰라도 베이크된 탭을 열거하기DefinitionDatabase.RecordsUntyped:

foreach (DefinitionDatabase db in myBakedDatabases)
    foreach (object record in db.RecordsUntyped)     // reflect on the fields you care about
        ;

이는 두 번째 베이커(동일한 시트를 자신만의 에셋으로 바꾸는 다른 제품)를 위해 승인된 경로다. private records 필드를 리플렉션하지 마라: 그렇게 하면 필드 이름이 선언되지 않은 계약이 되어, 코드젠이 그 이름을 바꾸는 날 조용히 깨질 것이다. 이 목록은 읽기 전용이다 — 시트가 정본이다. 이 멤버가 존재하기 전에 작성된 생성 코드에서는 기본적으로 비어 있다. 한 번의 재임포트가 그 오버라이드를 내보낸다.

생성된 클래스를 안전하게 확장하기 — 생성된 두 클래스는 모두 partial이므로, 파생 멤버(계산된 프로퍼티, 인터페이스 구현, 연산자)가 그 옆의 자신만의 파일에 살면서 모든 재임포트에서 살아남을 수 있다. 그곳에 직렬화된 필드는 추가하지 마라: 베이크된 ScriptableObject는 매 임포트마다 시트로부터 재구축되므로, 사용자의 부분만 직렬화하는 필드는 기본값으로 돌아온다. 값이 데이터에 속한다면, 그것은 컬럼에 속한다.

의도적으로 닫혀 있는 것

위의 표면들은 승인된 바깥 경계다. 아래의 것들은 공개하는 것이 아무리 편리해 보이더라도 internal로 남는다 — 이들 각각은 편의의 경계가 아니라 신뢰 또는 무결성의 경계이기 때문이다:

  • 자격 증명과 서명 — 서비스 계정 키 로케이터, JWT/PEM/PKCS8 프리미티브, Google 액세스 토큰 프로바이더. 이를 공개하면 어떤 플러그인에게든 사용자의 스프레드시트로 범위가 지정된 bearer 토큰을 넘겨주게 될 것이다.
  • 원본 push 체인(push 러너, 시트 게이트웨이, 셀 쓰기) — 승인(IPushApprover)은 그 오케스트레이션 안에서 강제된다. 공개된 원본 라이터는 승인 단계 없는 시트 쓰기가 될 것이다.
  • 전송 전 검증과 반영 쓰기 엔진 — 외부 코드는 오직 AuthoringDispatcher.Reflect()를 통해서만 들어오며, 이는 도중에 오래된 앵커 검사, 사전 검증, 승인을 통과시킨다. 그 아래의 쓰기 엔진은 계약이 아니다.
  • 베이크/코드젠 무결성 체인(스키마 핑거프린트, 생성 소스 라이터, 고아 정리)과 빌드 최신성 게이트 — 이를 공개하면 베이크 상태를 위조하거나 우회하는 것이 단 한 줄로 가능해질 것이다.
  • 임시 SO 오버레이 — "시트가 진실 공급원이다"에는 정확히 하나의 승인된 예외(인스펙터의 테스트 편집 토글)가 있으며, 이는 의도적으로 API로 제공되지 않는다.

어떤 워크플로우가 이들 중 하나를 필요로 하는 것처럼 보인다면, 그것에 필요한 것은 리플렉션이 아니라 기능 요청이다.

6. 생성 코드 배치와 네임스페이스

  • generatedCodeFolder어떤 폴더든 될 수 있다(동반 asmdef 자가 치유가 플러그인 타입 참조를 자동으로 연결한다). 하지만 자신의 패키지 안에 두는 것이(예: Assets/MyDomain/Runtime/Generated) 가장 깔끔하다 — 그러면 생성된 타입이 동반 asmdef 없이도 사용자의 enum/커스텀 타입과 동일한 어셈블리에서 컴파일된다.
  • 탭별 거처: 생성 타입이 이미 어딘가에 존재하는 탭은 그 자리에서 재생성된다 — 설정이 다른 곳을 가리키더라도 패키지에 커밋된 Generated 폴더가 계속 우선한다. 오래된 중복 파일은 자동으로 정리된다(로그로 남으며, 결코 조용히 처리되지 않는다).
  • **generatedNamespace**는 생성된 타입을 격리한다(예: MyGame.Data). 타입 발견은 네임스페이스가 아니라 생성된 타입 고유의 SchemaFingerprint 마커를 사용하므로, 어떤 네임스페이스든 동작한다. 이 값을 변경하면 자동으로 재생성이 트리거된다.
  • 패키지의 Generated 폴더를 커밋할지는 해당 패키지의 정책이다. 샘플은 자신의 것을 커밋한다(SheetForge.Generated의 기본 네임스페이스에 있는 Example* 클래스, ExampleSkills/ExampleEffects/ExampleActions 탭) — 그래서 새로 클론했을 때 즉시 컴파일되며, Example* 클래스 이름 접두사 — 별도의 네임스페이스가 아니라 — 가 사용자 프로젝트의 실제 Skills/Effects 탭과 충돌하지 않도록 지켜준다.

7. 런타임에서 소비하기 — "스크립트 대신 조립하라"

런타임은 생성된 데이터베이스를 읽고 type enum을 기준으로 코드 원자에 디스패치한다:

using SheetForge.Runtime;
using SheetForge.Generated;
 
var hSkills  = SheetForgeDatabases.LoadAsync<ExampleSkillsDatabase>("ExampleSkills");
var hActions = SheetForgeDatabases.LoadAsync<ExampleActionsDatabase>("ExampleActions");
var hEffects = SheetForgeDatabases.LoadAsync<ExampleEffectsDatabase>("ExampleEffects");
var runner = new SkillRunner(await hSkills.Task, await hActions.Task, await hEffects.Task);
// keep the handles for the system's lifetime; Release each on shutdown

진짜로 절차적인 일회성 로직에는, AssetRef를 통해 스크립트 에셋을 참조한다 — SheetForge는 참조를 검증하고 addressable을 베이크한다(이미지와 정확히 동일하게). 이를 실행하는 것은 게임의 몫이다.

8. 다른 에셋에서 SheetForge 감지하기

다른 에셋 — SheetForge를 확장하는 것이 아니라 연동하는 에셋(예를 들어 스탯 시스템) — 도 SheetForge가 설치되어 있는지 감지할 수 있다. 유료 Asset Store 제품은 폴더 제품이므로(package.json / UPM 없음) versionDefines 항목을 제공할 수 없다. 대신 SheetForge의 Editor 어셈블리가 모든 빌드 타깃에 SHEETFORGE 스크립팅 define 심볼을 자체 등록한다.

(a) 컴파일 타임(권장):

  • 연동 코드가 자신만의 어셈블리 정의에 있다면, 그 asmdef의 Define ConstraintsSHEETFORGE를 추가한다 — 그러면 그 어셈블리는 SheetForge가 있을 때만 컴파일된다.
  • SheetForge를 다루는 코드가 무조건 컴파일되어야 하는 코드와 같은 어셈블리를 공유한다면, 해당 부분만 #if SHEETFORGE … #endif로 가드한다.

(b) 에디터 타임(대안): 컴파일 순서에 의존할 수 없는 경우, 리플렉션으로 프로브한다 — 예: System.Type.GetType("SheetForge.Editor.Pipeline.ImportEvents, SheetForge.Editor") != null — 그런 다음 (예를 들어) 완료 버스를 동적으로 연결한다.

SHEETFORGE는 *"SheetForge가 설치되어 있다"*는 뜻이다. 이는 SHEETFORGE_ADDRESSABLES와는 별개다 — 후자는 SheetForge 자체 어셈블리에 있는 내부 버전 정의로, Addressables 패키지가 있는지만 표시한다. 후자를 설치 감지 용도로 사용하지 말 것.

이 define은 이후 SheetForge가 제거되더라도 남아 있는다(이를 해제할 감시자가 없다). Project Settings ▸ Player에서 수동으로 제거하라. 기능 및 제한 참고.

Core 수정이 여전히 필요한 부분

위의 모든 것은 Core 수정 없이 합류한다. 플러그인이 Core 변경 없이는 여전히 할 수 없는 것:

  • 마커 값을 생성 코드로 내보내는 것 — 커스텀 마커는 검증/표시용 메타데이터다. 이를 코드젠 상수나 애트리뷰트로 베이크하는 것은 소비자가 필요로 하기 전까지는 범위 밖이다.
  • 어떻게 해야 하는지 알려주지 않아도 키 이름 변경 전파가 커스텀 표기법 안에 도달하게 하는 것 — 이름이 변경된 레코드는 Core 스스로 RecordId@Tab 셀, 그 리스트, 래퍼 요소 안에서 재작성된다. 자신만의 문법에 대해서는, IReferencingCellType(§4.4a)을 구현하면 페이로드가 보존된 채로 재작성된다 — 이는 옵트인이지 Core 수정이 아니다. 이 옵트인을 거부하면 그 경계는 그대로 남는다: 이름 변경이 조용히 고쳐주는 대신, 사용자의 도메인 검증기가 매달린 키를 보고한다.
  • 시트로부터 플러그인이 등록한 C# enum에 멤버를 추가하는 것enums.Register<T>()로 등록된 enum은 코드가 소유하므로, enum 정의 시트는 이를 확장할 수 없으며 데이터 스튜디오는 그 행을 제공하지 않는다. 시트가 소유해야 한다면 enum을 enum 시트로 옮겨라(시트 문법 참고).

<> 래퍼 타입(ICellWrapperType, §2 참고)과 커스텀 구조 마커(IStructuralMarkerDefinition, §4.5 참고) 모두 Core 수정 없이 파이프라인을 확장한다.

관련 페이지

  • 시트 문법 — 등록된 타입이 시트에 어떻게 나타나는지
  • 데이터 스튜디오 — 캔버스 오버라이드, 코드 레지스트리, 위젯, 액션이 나타나는 곳
  • API 참조 — 모든 계약의 전체 시그니처
  • 작성 커널 — 엣지와 엔진 표면
  • 기능 및 제한 — 플러그인 확장의 경계(래퍼 거부 규칙, 마커 제한)와 예약된 이음매