From 205af32da4922a8df53aa5c3e0668a33b4aa359d Mon Sep 17 00:00:00 2001 From: qwreey-agent-selene Date: Wed, 19 Aug 2026 14:01:07 +0900 Subject: [PATCH] =?UTF-8?q?tooling:=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8?= =?UTF-8?q?=20=EC=85=8B=EC=97=85=20=EB=AC=B8=EC=84=9C=ED=99=94=20+=20wally?= =?UTF-8?q?=E2=86=92pesde=20=EC=A0=84=ED=99=98,=20M1=20=EC=8A=A4=EC=BA=90?= =?UTF-8?q?=ED=8F=B4=EB=94=A9=20=EA=B3=A8=EA=B2=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pesde 워크스페이스 실제 설치·검증(패키지명 하이픈 금지, workspace 의존성 문법, per-package pesde.lock), init.luau의 @self require 규칙(Luau RFC 확인), 워크스페이스 의존성이 심볼릭 링크라 luau CLI의 require-by-string과 충돌하는 함정을 base/project-setup-plan.md로 정리. architecture.md 패키징 방식 절도 같이 정정. Relate.luau/New()-InitXxx 골격/mock 하네스는 이 구조를 실제로 검증하는 과정에서 나온 최소 스캐폴딩. Co-authored-by: qwreey --- .claude/README.md | 3 +- .claude/base/architecture.md | 44 +++- .claude/base/project-setup-plan.md | 212 ++++++++++++++++ ...19-04-pesde-migration-and-project-setup.md | 107 +++++++++ .gitignore | 7 + .luaurc | 10 + default.project.json | 15 ++ pesde.lock | 12 + pesde.toml | 13 + quad-base/pesde.lock | 6 + quad-base/pesde.toml | 9 + quad-base/src/Debug/init.luau | 25 ++ quad-base/src/Relate.luau | 92 +++++++ quad-base/src/init.luau | 26 ++ quad-base/test/mock.luau | 227 ++++++++++++++++++ quad-base/test/smoke.mock.luau | 59 +++++ quad-roblox/pesde.lock | 13 + quad-roblox/pesde.toml | 12 + rokit.toml | 10 + 19 files changed, 890 insertions(+), 12 deletions(-) create mode 100644 .claude/base/project-setup-plan.md create mode 100644 .claude/session/2026-08-19-04-pesde-migration-and-project-setup.md create mode 100644 .luaurc create mode 100644 default.project.json create mode 100644 pesde.lock create mode 100644 pesde.toml create mode 100644 quad-base/pesde.lock create mode 100644 quad-base/pesde.toml create mode 100644 quad-base/src/Debug/init.luau create mode 100644 quad-base/src/Relate.luau create mode 100644 quad-base/src/init.luau create mode 100644 quad-base/test/mock.luau create mode 100644 quad-base/test/smoke.mock.luau create mode 100644 quad-roblox/pesde.lock create mode 100644 quad-roblox/pesde.toml create mode 100644 rokit.toml diff --git a/.claude/README.md b/.claude/README.md index 4903ac3..81948f5 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -43,7 +43,8 @@ | 문서 | 내용 | |---|---| -| `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서). **[2026-08-12 세션 신설, 같은 날 후속 세션에서 강화]** "코드 스타일 — Luau 문법 관례" 절 신설 — `if-then-else`가 공식 Luau 문법임을 명문화(환각/오타로 오인해 `and`/`or`로 되돌리는 회귀 방지), `A and B or C` 삼항 관용구는 항상-truthy 예외도 없이 전면 금지로 강화(`bind-system-plan.md`의 `retractUnder` falsy-값 버그가 실사례). `const` 바인딩은 공식 문법이나 툴링 미성숙으로 지금은 채택 보류 | +| `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서). **[2026-08-12 세션 신설, 같은 날 후속 세션에서 강화]** "코드 스타일 — Luau 문법 관례" 절 신설 — `if-then-else`가 공식 Luau 문법임을 명문화(환각/오타로 오인해 `and`/`or`로 되돌리는 회귀 방지), `A and B or C` 삼항 관용구는 항상-truthy 예외도 없이 전면 금지로 강화(`bind-system-plan.md`의 `retractUnder` falsy-값 버그가 실사례). `const` 바인딩은 공식 문법이나 툴링 미성숙으로 지금은 채택 보류. **[2026-08-19 정정]** 패키징 매니저를 wally에서 pesde로 전환 — 세부는 `project-setup-plan.md` | +| `project-setup-plan.md` | **[2026-08-19 신설]** M0/M1 스캐폴딩을 실제로 pesde/`luau` CLI로 굴려보고 검증한 결과 — pesde 워크스페이스 구조(`workspace_members`, 패키지 이름은 하이픈 금지), `rokit.toml` 툴체인 핀(실제 설치·버전 일치 확인), `init.luau`에서 `@self`가 필수인 이유(Luau RFC `abstract-module-paths-and-init-dot-luau`), 워크스페이스 의존성이 심볼릭 링크로 연결되고 `luau` CLI의 require-by-string이 심볼릭 링크를 안 따라가는 실측 함정과 우회, `.luaurc` alias 런타임 미지원 재확인, `pesde.lock` 커밋 권고(잠정). "확인 완료/아직 확인 안 된 것" 절이 다음에 뭘 검증해야 하는지의 소스 | | `typing-limits.md` | **[2026-08-13 열세 번째 세션 신설]** Luau 타입 시스템이 quad 설계에 대해 **못 해주는 것**을 한 군데 모은 확정 문서 — 여러 `base/` 문서에 캐비엇으로 흩어져 있던 걸 통합. 대전제는 "**Luau의 한계를 우회하려고 타입/API를 비틀지 않는다**"(비틀면 나중에 Luau가 고쳐줘도 자동 수혜를 못 받고 되돌리는 마이그레이션이 생김). 1번 항목이 가장 큼 — **재귀 제네릭이 다른 타입 인자로 자기를 반환하면(`Compute(self: State,...) -> State`) 타입 안전성이 에러 없이 조용히 사라짐**(구 `question.md` 0-Y, 스파이크 다수로 확정 — 근거·개수는 `audit/type-recursion-issue/`). 대응은 두 개: (a) 타입 선언을 "데이터부/메소드부"로 쪼개 콜백 파라미터 추론을 살리고, (b) **파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩**(그 한 줄만 검증 안 되고 다운스트림 전체는 정상 체크됨). Luau RFC `relax-recursive-type-restriction`이 `Promise.andThen`으로 예시 든 바로 그 패턴이라 **지금 선언 그대로 두면 Luau 쪽 수정만으로 코드 변경 없이 풀림**(추적: `luau-lang/luau#2380`). **[2026-08-15 추가]** ③ 인라인 대신 이름 붙은 함수 + `typeof`로 선언하면 콜백 파라미터 주석은 여전히 필요하지만 LHS 명시 없이도 다운스트림이 안전해짐(①을 대체하지 않음, 보강). 그 외 Modifier `Overridden` 서브타입/Attribute 제네릭 키 narrowing/nilable default 오버로드도 여기 통합, `store.key` type function 한계는 **검증 완료로 승격**(§5), 7번에 **새 타입·API 설계 시 체크리스트**. 실측 근거는 `audit/type-recursion-issue/` + `audit/type-recursive-issue-with-typeof/` | | `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식. **[2026-08-14 다섯 번째 세션, 시그니처 정정]** `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)` — 뒤의 둘은 `inst`를 안 받음(`bindLifetime`이 바인딩 시점에 gcconn 참조를 `value` 쪽 `Relate`로 복사해두므로 `value` 하나로 생존을 물을 수 있고, 실제 호출부인 State 전파 루프엔 애초에 `inst`가 없음). `.Subscribed`는 전역 `:Subscribe()` 전용 필드로 분리(`bindLifetime`은 읽지도 쓰지도 않음), gcconn/gchold는 lazy가 아니라 **Instance 생성 시점**에 만들고 클로저가 `gchold`와 `inst`를 둘 다 캡처(userdata 포인터 동일성 = `inst`-키 `Relate` 전체의 전제). 옛 2-인자 모델은 `archive/canexecute-inst-arg-reversed.md`. **[2026-08-14 열한 번째 세션]** 별도 `canBound`가 다시 도입됨 — `bindLifetime`/`Observer:Subscribe()`의 이중 바인딩 가드는 `canBound`, State emit 전파 게이팅만 `canExecute`(판정 로직은 비공개 헬퍼 `isBoundAlive` 하나를 공유). **[2026-08-18 구현 전 QA 반영]** **두 predicate는 값이 같은 게 아니라 서로의 부정**(`canBound` 참 = "지금 묶어도 됨")이라 게이트가 전부 `if not canBound(v) then error(...)`로 정정됨 — 옛 서술대로 짰으면 정상 첫 바인드가 전부 에러났음. gcconn/gchold 저장도 `SetStrong`→**`SetWeak`** 정정 | | `store-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** Store = **이름 붙은 Source 모음, 그 이상 아님** — Store 부작용 허용이 기본 디자인(국소적 vs 경계를 넘는 부작용), `defaults`는 선택적 초기값 템플릿(원본을 나중에 mutate해도 UB 아님)이고 **eager 생성과 lazy 생성이 둘 다 필요**(Luau 타입은 런타임에 강제 안 되므로), `table.clone` 기반 eager 생성 스케치, `store.key`(dot-access)가 1급 경로(**[2026-08-18] `store "key"` 문자열 커링은 기각** — 동적 키는 `store:GetDynamic<>(name)`), 레코드 필드 타이핑은 Luau `type function`으로 해결 확인, `store.key = value` 폐기 → `store.key:Set(value)`(타입 대칭성+lazy 정직성), "Store가 Store를 저장 가능한가"는 **그런 경우를 안 만듦**으로 확정(`State>`와는 다른 축) | diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index ce29eeb..ae1a0a0 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -177,17 +177,39 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo 아래가 다음 세션에서 실제로 만들 구조. 지금은 문서 확정까지만, 실제 폴더/`wally.toml`/`project.json` 스캐폴딩은 다음 세션. -**패키징 방식(모노레포, RbxUtil 선례 채택)**: 최종적으로는 여러 개의 독립 -wally 패키지로 나누고 싶지만, 지금 Luau 툴링(특히 wally로 설치된 패키지의 -타입 정보 단절·`luau-lsp`의 심볼릭 링크 해석 문제 — 최근 `luau-lsp 1.63.0` -에서야 수정됨)이 아직 불안정해서 **당장은 모놀리식**으로 감. `Sleitnick/ -RbxUtil`이 정확히 이 패턴(루트 하나로 통합 개발/테스트, 서브폴더마다 자체 -`wally.toml`로 독립 퍼블리시)을 쓰는 선례라 그대로 채택. `.luaurc`의 -`aliases`는 **런타임 require에서 아직 엔진이 지원 안 함**(Roblox 스태프가 -지원 예정이라고만 밝힌 상태, 2026-01 기준) — 그래서 alias는 편집기 -자동완성/타입체크용으로만 곁들이고, 실제 크로스패키지 require는 상대경로로 -쓴다. 나중에 실제로 레포를 쪼갤 때는 Rojo `project.json`의 트리 매핑 규칙만 -유지하면 되고, require는 그 시점에 한 번 기계적으로 바꾸는 정도로 감수. +**패키징 방식(모노레포, RbxUtil 선례 채택) — [2026-08-19 정정] 패키지 +매니저를 wally에서 pesde로 전환.** 원래는 wally 툴링 불안정(설치된 패키지의 +타입 정보 단절·`luau-lsp` 심볼릭 링크 해석 문제)을 이유로 "최종적으론 독립 +패키지로 쪼개고 싶지만 당장은 모놀리식"으로 타협했었는데, **사용자 결정 +(2026-08-19): pesde로 간다** — dev-dependency를 1급으로 지원하는 등 wally보다 +툴링이 낫다는 판단. **모노레포 자체의 모양(루트 통합 개발, 서브패키지마다 +독립 게시)은 안 바뀜** — `Sleitnick/RbxUtil`이 wally로 하던 바로 그 패턴을 +pesde는 **네이티브 workspace**(Cargo 워크스페이스와 동형: 루트 +`workspace_members` + 멤버 간 `{ workspace = "scope/name", version = "^" }` +의존)로 처음부터 1급 지원하므로, wally가 안고 있던 타입 정보 단절 문제 +자체도 이 전환으로 같이 해소될 것으로 기대(단, 이 세션은 pesde 바이너리가 +없는 샌드박스라 `pesde install`로 실제 링크 결과까지는 검증 못함 — 실제 +설치 검증은 사람이 로컬에서 `rokit install` 이후 확인할 것). +실제 구현: 루트 `pesde.toml`(`private = true`, +`workspace_members = ["quad-base", "quad-roblox"]`) + `quad-base/pesde.toml`/ +`quad-roblox/pesde.toml`(각각 `[target] environment = "roblox"`, +`quad-roblox`는 `quad-base`에 workspace 의존), 툴체인은 `rokit.toml`로 핀. +pesde는 "패키지 안에 `default.project.json`을 두지 말 것"이 컨벤션(그 +파일은 소비자가 직접 만드는 sync 설정 몫) — 루트의 `default.project.json`은 +이 규칙의 예외가 아니라 애초에 그 규칙이 가리키는 대상이 아님(워크스페이스 +루트 자신의 통합 개발/테스트용, 게시되는 패키지 안이 아니므로). +`.luaurc`의 `aliases`는 여전히 **런타임 require에서 엔진이 지원 안 함** +(2026-08-19 이 세션에 커스텀 alias로 직접 재확인 — `require("@alias/x")`가 +"could not jump to alias"로 실패) — 그래서 alias는 편집기 자동완성/타입체크용으로만 +곁들이고, 실제 크로스패키지 require는 상대경로로 쓴다(단, `init.luau` +안에서는 평범한 상대경로가 아니라 **예약 alias `@self`**를 써야 함 — +`init.luau`는 require-by-string 상 "자기 폴더 자체"를 가리키므로 `./`가 +아니라 `@self/`로 그 폴더 안의 형제 파일에 접근한다, 2026-08-19 사용자 지적 ++ Luau RFC `abstract-module-paths-and-init-dot-luau`로 확인 — 실제로 이 +세션의 `quad-base/src/init.luau`가 이 착오로 크로스파일 require가 전부 +깨졌다가 `@self`로 고치고 나서야 정상화됨). 나중에 실제로 레포를 쪼갤 때는 +Rojo `project.json`의 트리 매핑 규칙만 유지하면 되고, require는 그 시점에 +한 번 기계적으로 바꾸는 정도로 감수. **패키지 경계**: `quad-base`는 다른 렌더 백엔드(GTK 등, 항목 12 참고)에서도 재사용 가능해야 한다는 전제 — Store/State/Source 온톨로지+전파뿐 아니라 diff --git a/.claude/base/project-setup-plan.md b/.claude/base/project-setup-plan.md new file mode 100644 index 0000000..ef7ea59 --- /dev/null +++ b/.claude/base/project-setup-plan.md @@ -0,0 +1,212 @@ +# 프로젝트 셋업 — pesde 워크스페이스, `.luaurc`, require 구조 + +**상태**: base — `architecture.md`의 "구현 착수: 소스 트리 구조 확정" 절이 +정한 소스 트리를 **실제로 pesde/luau CLI로 셋업해보고 검증한 결과**. 그 +절은 "무엇을 어디에 두는가"까지만 다루고 "그걸 실제로 어떻게 굴리는가"는 +비워뒀는데, 이 문서가 그 나머지 — 패키지 매니저 조작, require 문법, +현재 환경에서 확인된 한계까지. **[2026-08-19 신설, 같은 날 pesde 실제 +설치·`pesde install` 실행으로 검증]** + +**전제**: 이 문서가 서술하는 건 **M0/M1 스캐폴딩 단계에서 확인된 사실**이지 +M3 이후 실제 구현이 아님 — `quad-base/src`는 아직 `Relate.luau`/골격 +`New()`/`Debug` 서브시스템뿐이고 `quad-roblox/src`는 비어 있음 +(`.claude/todos.md`가 여전히 진행 상황의 소스). 여기 적힌 require/pesde +규칙은 실제 소스가 늘어나도 안 바뀔 구조적 사실이라 base로 승격했지만, +"무엇이 구현됐는가"는 이 문서가 아니라 `todos.md`/`ROADMAP.md`를 볼 것. + +## 왜 wally가 아니라 pesde인가 + +**사용자 결정(2026-08-19)**: dev-dependency를 1급으로 지원하는 등 wally보다 +툴링이 낫다는 판단. 상세 배경/재검토는 `architecture.md`의 "구현 착수: +소스 트리 구조 확정" 절 "패키징 방식" 문단이 소스 — 여기서 반복하지 +않음. 요지만: 모노레포 모양(루트 통합 개발, 서브패키지마다 독립 게시) +자체는 안 바뀌고, pesde의 네이티브 workspace 기능이 그 모양에 그대로 +들어맞는다. + +## pesde 워크스페이스 구조 + +``` +quad/ +├── pesde.toml # 워크스페이스 루트, private = true, workspace_members +├── rokit.toml # pesde/rojo 버전 핀 +├── quad-base/ +│ └── pesde.toml # name = "qwreey/quad_base" +└── quad-roblox/ + └── pesde.toml # name = "qwreey/quad_roblox", quad_base에 workspace 의존 +``` + +- **루트 `pesde.toml`**: `private = true`(게시 안 됨) + `workspace_members = + ["quad-base", "quad-roblox"]`. `[target] environment = "roblox"`도 + 필요(공식 workspace 가이드 예제가 루트에도 `[target]`을 요구함 — 이 + 세션엔 `roblox` 하나만 있어 실제로 검증 안 됨, 필요 여부/의미는 M5 이후 + 재확인 후보). +- **서브패키지 `pesde.toml`**: `[target] environment = "roblox"` + + `build_files = ["src"]` + `lib = "src/init.luau"`. `quad-roblox`는 + `[dependencies] quad_base = { workspace = "qwreey/quad_base", version = + "^" }`. +- **⚠️ 패키지 이름은 `a-z`/`0-9`/`_`만 허용 — 하이픈 금지**(`pesde + install` 실측: `qwreey/quad-base`는 파싱 단계에서 바로 거부됨, 에러 + 메시지가 "did not match any variant of untagged enum + DependencySpecifiers"로 나와서 원인 파악에 혼동을 줌 — 실제 원인은 이름 + 문자 제약이지 의존성 선언 문법이 아니었음). **`quad_base`/`quad_roblox`로 + 확정** — 폴더 이름(`quad-base`/`quad-roblox`)은 `architecture.md`가 + 이미 확정해둔 것이라 그대로 두고, `pesde.toml`의 `name` 필드만 언더스코어로 + 다르게 쓴다. 둘이 다르다는 걸 헷갈리지 말 것. +- **`pesde add`는 워크스페이스 멤버를 자동으로 못 찾는다** — 레지스트리 + 검색 전용 커맨드라 로컬 워크스페이스 형제를 이름으로 넘기면 + "package not found"로 실패한다. `[dependencies]`의 `workspace = "scope/name"` + 줄은 **직접 손으로 쓸 것**(위 표기 그대로 — 실제로 `pesde install`이 + 받아들이는 걸 확인함). +- **`pesde install`은 워크스페이스 루트에서 한 번**만 돌리면 전체가 + 갱신됨(`qwreey/quad`/`qwreey/quad_base`/`qwreey/quad_roblox` 셋 다 + 스캔·링크). + +## 툴체인 — `rokit.toml` + +`initreq/vide`(참고 레포)의 `rokit.toml` 선례를 따르되 wally 항목은 +뺐다. **[2026-08-19] 이 세션이 `/code/.local/bin`에 pesde 바이너리를 +직접 다운로드해 설치·검증 완료** — `pesde 0.7.3`, `rokit.toml`의 핀 +(`pesde-pkg/pesde@0.7.3+registry.0.2.3`)과 정확히 일치. `rojo` 핀은 +GitHub 릴리스 페이지 조회로만 확인했고 실제 설치·실행 검증은 안 함(Rojo는 +Studio 연동이 필요해 `HUMAN_TODO.md` 1번과 같은 처지) — 버전이 오래되면 +`rokit add rojo-rbx/rojo`로 다시 확인할 것. + +## require 구조 — `@self`가 필수인 이유 + +**[2026-08-19, 사용자 지적 + Luau RFC `abstract-module-paths-and-init-dot-luau`로 확인]** + +`init.luau`라는 파일은 require-by-string 상 **자기가 든 폴더 자체**를 +가리키는 특수 취급을 받는다 — 그래서 그 파일 **안에서** 쓰는 상대 +경로는 일반 파일과 기준점이 다르다: + +- **`init.luau` 안에서 `require("./X")`/`require("../X")`** — 이 폴더 + 자체가 "자기"이므로, 상대 경로는 **이 폴더의 형제/조상**을 가리킨다. + 즉 `quad-base/src/init.luau` 안의 `./Debug`는 `quad-base/src/Debug`가 + 아니라 `quad-base/Debug`를 가리킨다(존재하지 않으면 즉시 에러). +- **`init.luau` 안에서 그 폴더 *안의* 형제 파일에 접근하려면 + `@self/X`를 써야 한다** — `@self`는 예약 alias로, 이 모듈(=이 폴더) + 자신의 경로로 치환된다. `quad-base/src/init.luau`가 + `quad-base/src/Debug`에 접근하려면 `require("@self/Debug")`. +- **일반 파일(`init.luau`가 아닌 `*.luau`)에서는 평범한 파일-상대 + 경로**(`./`/`../`)면 충분 — `@self`가 전혀 필요 없다. 예: + `quad-base/src/Debug/init.luau`(이것도 init.luau라 위 규칙이 적용됨) + 안에서 형제 `Relate.luau`(`quad-base/src/Relate.luau`, `Debug` + 폴더의 부모에 있음)를 가져오려면 — `Debug/init.luau`의 "자기"는 + `Debug` 폴더이므로 그 부모(=`quad-base/src`)에 있는 `Relate.luau`는 + 형제 폴더 취급 → `require("./Relate")`(◯), `require("../Relate")`(✕, + 한 단계 더 올라가 `quad-base/Relate`를 찾으려다 실패). + +**실측 근거**: `tbox`(`initreq/tbox`, 다른 참고 레포)의 `src/init.luau`가 +`require("@self/types")`/`require("@self/schema/string")` 패턴을 +실제로 쓰고 있어 교차 확인됨. 이 세션에서 `quad-base/src/init.luau`가 +처음에 `require("./Debug")`로 잘못 짜여 크로스파일 require가 전부 +깨졌었고(런타임은 크래시, `luau-analyze`는 **조용히** `Unifiable`로 +새며 0 진단으로 통과 — 이것도 `typing-limits.md`의 "실측 방법 주의" +경고("`luau-analyze`가 진단 0건이어도 타입이 제대로 해소됐다는 뜻이 +아닙니다")가 가리키는 것과 같은 종류의 함정, 다만 원인은 재귀 제네릭이 +아니라 require 경로 오류라 그 문서 1번 항목과는 별개 사례), `@self`로 +고치자 즉시 정상화됨(둘 다 clean). + +**체크리스트**: 새 `init.luau`를 짤 때마다 "이 파일 안의 `require`가 +같은 폴더 안의 형제를 가리키는가, 아니면 이 폴더의 형제/조상을 +가리키는가"를 먼저 물을 것 — 전자면 `@self/`, 후자면 `./`나 `../`. + +## 워크스페이스 의존성은 심볼릭 링크로 연결된다 — CLI 테스트의 함정 + +**[2026-08-19 실측]** `pesde install`이 워크스페이스 멤버 간 의존성을 +해소하는 방식은 **심볼릭 링크**다 — `quad-roblox/roblox_packages/` +안에 실제로 이렇게 생긴다: + +``` +quad-roblox/roblox_packages/ +├── quad_base.luau # 얇은 링커: return require("./.pesde/qwreey+quad_base/0.0.0/quad_base/src") +└── .pesde/qwreey+quad_base/0.0.0/quad_base/ + ├── src -> ../../../../../quad-base/src (symlink) + ├── test -> ../../../../../quad-base/test (symlink) + ├── pesde.toml -> ... (symlink) + └── pesde.lock -> ... (symlink) +``` + +**⚠️ 문제**: Luau의 standalone require-by-string 구현은 **심볼릭 링크를 +안 따라간다** — 의도된 설계다(Luau RFC 검색 결과: "보안 상의 이유로 +symlink는 일반 파일처럼 취급되고 따라가지 않는다", 추후 `.luaurc`에 +opt-in 토글이 추가될 수 있다고만 언급됨, 아직 없음). 직접 재현: + +```lua +-- entry.luau, ./linked가 실제 폴더로의 symlink일 때 +local v = require("./linked") +-- error requiring module "./linked": could not resolve child component "linked" +``` + +**실무 영향**: `quad-roblox`가 실제로 `quad_base`를 쓰게 되면(M5+), +표준 경로(`require(".../roblox_packages/quad_base")`)는 **`luau` CLI로 +직접 못 돌린다** — `could not resolve child component`로 즉시 깨짐. +Roblox Studio/Rojo sync는 이 문제와 무관할 가능성이 높음(Rojo는 Luau의 +require 리졸버가 아니라 평범한 파일시스템 워크로 트리를 만듦 — **단, +이 세션은 Rojo가 설치돼 있지 않아 실측은 못 했음, `HUMAN_TODO.md`에 +Studio 연동이 되면 같이 확인할 것**). + +**임시 우회(M0/M1식 CLI 스파이크/mock 테스트 한정)**: `roblox_packages/`를 +거치지 말고 실제 형제 패키지 경로를 직접 가리킬 것 — 예: +`quad-roblox/src`에서 검증용 스크립트를 짤 때 +`require("../../quad-base/src")`처럼. **프로덕션 `quad-roblox` 소스 +자체는 그대로 표준 pesde 경로(`roblox_packages/quad_base`)를 쓸 것** — +Rojo/Studio가 실제로 소비하는 건 그 경로고, 위 우회는 어디까지나 이 +환경의 CLI 스파이크 테스트용 임시 조치. Rojo의 symlink 처리가 실측 +확인되면 이 절 자체가 불필요해질 수 있음(그때 다시 볼 것). + +## `.luaurc` — alias는 여전히 편집기 전용 + +`.luaurc`의 `aliases`(`@quad-base`/`@quad-roblox`)는 **런타임 +require에서 여전히 안 먹는다** — `architecture.md`가 이미 이렇게 +서술해뒀던 걸 이 세션에 직접 재확인: `require("@quad-base/Debug")`를 +실행하면 `could not jump to alias "quad-base/src"`로 실패(별도 에러 +메시지라 위 심볼릭 링크 문제와는 다른 원인 — alias 자체가 런타임 +미지원이라는 뜻, `@self`는 **예약 alias**라 이 제약과 무관하게 항상 +동작하는 것과 구분할 것). 그래서 alias는 편집기 자동완성/타입체크 +용도로만 남기고, 실제 require는 위 규칙대로 상대경로 + `@self`. + +## `pesde.lock` — 커밋 권고 (미확정, 사용자 판단 필요) + +**[2026-08-19 실측, 최초 서술 정정]** 처음엔 "워크스페이스 루트에 딱 +하나만 생긴다"고 적었으나 **틀렸음** — 실제로는 `pesde install`이 +**워크스페이스 멤버마다 각자의 `pesde.lock`도 같이 만든다**(루트 +`pesde.lock` 1개 + `quad-base/pesde.lock` + `quad-roblox/pesde.lock`, +총 3개). 루트 것은 `[workspace."qwreey/quad_base"]`류 멤버 매핑만 +담고, 멤버 것들은 각자의 실제 의존성 그래프를 담는다(`quad_roblox`의 +lock엔 `[graph."qwreey/quad_base@0.0.0 roblox"]` + `pkg_ref.ref_ty = +"workspace"`가 있음, `quad_base`는 의존성이 없어 메타데이터만). + +**이 세션의 잠정 권고는 셋 다 커밋**: Cargo 생태계의 "라이브러리는 +lockfile을 커밋하지 않는다" 관행이 여기 그대로 적용 안 되는 이유는, 이 +lockfile들이 **게시되는 대상이 아니기** 때문 — 루트는 `private = true`, +`quad-base`/`quad-roblox`의 `pesde.toml`도 `includes = ["src/*"]`뿐이라 +`pesde.lock`은 애초에 게시물에 안 들어감(외부 소비자는 이 파일들을 절대 +못 봄 — 자기 프로젝트에서 새로 resolve함). 그래서 "라이브러리 lockfile +딜레마" 자체가 성립하지 않고, 그냥 "이 모노레포를 체크아웃한 개발자/CI가 +재현 가능한 빌드를 얻는가" 문제로 좁혀지는데 그건 커밋하는 쪽이 유리 — +**다만 이건 이 세션의 판단이고 최종 확정 아님**, `todos.md`에 확인 필요 +항목으로 반영. + +## 확인 완료 / 아직 확인 안 된 것 + +**확인 완료(이 세션, 실제 pesde/luau 실행 근거)**: +- pesde 워크스페이스 설치가 3개 패키지(루트+2서브) 전부에 대해 성공 +- 패키지 이름 문자 제약(하이픈 금지) +- `workspace = "scope/name"` 의존성 선언 문법 +- `@self`가 `init.luau`의 형제 파일 접근에 필수라는 것(런타임+ + `luau-analyze` 양쪽) +- `.luaurc` alias가 런타임에서 여전히 안 먹는다는 것(재확인) +- 워크스페이스 의존성이 symlink로 연결되고, 그게 `luau` CLI의 + require-by-string과 충돌한다는 것(직접 재현 + Luau RFC로 원인 확인) + +**아직 확인 안 됨(다음에 도구/환경이 갖춰지면)**: +- 루트 `pesde.toml`의 `[target]` 섹션이 실제로 의미가 있는지(지금은 + workspace 가이드 예제를 그대로 따라 둔 것, 검증 안 됨) +- Rojo가 이 symlink들을 실제로 따라가서 Studio에 정상 동기화하는지 — + `HUMAN_TODO.md` 1번(Studio 연동)이 되면 확인 +- `quad_base` 설치 시 나온 "`roblox_sync_config_generator` 스크립트가 + 없으면 linking에 문제가 생길 수 있다"는 WARN의 실제 영향 범위 — 지금은 + install 자체를 막지 않아서 방치, 실제 Rojo 동기화 단계에서 문제가 + 드러나면 그때 pesde 문서의 `[target.scripts]` 절을 찾아볼 것 +- `pesde.lock` 커밋 여부 최종 확정(위 절 권고는 잠정) diff --git a/.claude/session/2026-08-19-04-pesde-migration-and-project-setup.md b/.claude/session/2026-08-19-04-pesde-migration-and-project-setup.md new file mode 100644 index 0000000..0e8c1b9 --- /dev/null +++ b/.claude/session/2026-08-19-04-pesde-migration-and-project-setup.md @@ -0,0 +1,107 @@ +# 2026-08-19, 네 번째 세션 — M0/M1 스캐폴딩 첫 시도, wally→pesde 전환, `@self` require 함정 + +**요약**: 사용자 요청으로 M0(스파이크)/M1(스캐폴딩)을 revert 가능한 +상태로 실제로 짜보며 문제를 찾는 시도. 그 과정에서 진짜 문제(require +경로 버그)를 하나 찾았는데 원인 진단이 틀렸었고, 사용자가 직접 정답 +(`@self`)을 지목해 정정. 이어서 사용자가 패키지 매니저를 wally에서 +pesde로 바꾸자고 결정, tbox 참고 후 실제 pesde를 설치해 워크스페이스 +전체를 검증. 산출물은 `.claude/base/project-setup-plan.md`(신설)와 +`architecture.md`의 "패키징 방식" 절 정정. + +## 1. M0/M1 첫 실제 시도 + +`ROADMAP.md` M0(스파이크 3종)/M1(스캐폴딩)을 실제로 Luau로 짜봄: +- M0 항목 3(재귀 재-dispatch)은 기존 스파이크 `03`을 재실행해 여전히 + 유효함만 확인(재작성 불필요). +- M0 항목 1(다이아몬드 전파)은 `05-store-state-diamond-propagation.luau`를 + "emit은 항상 전파 + `:Get()` 시점 캐시로만 dedup" 현행 모델로 재작성, + 통과 후 `done/`으로 이동. +- `todos.md`가 M0 항목으로 요구하던 "Store 미선언 키가 타입 에러 나는가"도 + 새 스파이크 `21`로 확인 — `luau-analyze`가 정확히 2건의 `TypeError`로 + 거부함을 확인(사용자의 "아마 그럴 것" 추측이 맞았음). +- M1: `quad-base/`, `quad-roblox/` 폴더, `Relate.luau`(전량 구현), + `Debug/init.luau` + 최상위 `init.luau`(`New()`/`InitXxx` 팩토리 + 체이닝 + `Relate` 기반 멱등 가드), `quad-base/test/mock.luau`(최소 + mock) + 스모크 테스트까지 작성. + +## 2. require 버그 — 원인을 잘못 짚었다가 사용자가 정정 + +`quad-base/src/init.luau`(`require("./Debug")`)가 크로스파일 require +전부 실패. 여러 각도로 재현하다 "standalone `luau` CLI가 relative +require를 **process CWD** 기준으로 푼다"는 결론을 냈고, 이 결론으로 +첫 보고를 마쳤음(**틀린 진단**). + +사용자가 바로잡음: *"init.luau 는 상위 폴더를 자신으로 만들어낸다는 +의미라, @self 로 주변 요소를 접근해야해"* + Luau 공식 문서 링크 제공. +`rfcs.luau.org/abstract-module-paths-and-init-dot-luau`를 확인한 결과: +`init.luau`는 require-by-string 상 **자기가 든 폴더 자체**를 가리키는 +특수 케이스라, 그 안의 `./X`는 그 폴더의 형제를 가리키고, 폴더 +**안의** 형제 파일을 가리키려면 예약 alias `@self/X`가 필요함 — CWD와는 +무관한 문제였음. `quad-base/src/init.luau`의 `require("./Debug")`→ +`require("@self/Debug")`, `Debug/init.luau`의 `require("../Relate")`→ +`require("./Relate")`로 고치자 즉시 정상화(런타임 clean, `luau-analyze` +0 진단). 부수로 `Relate.luau`의 진짜 타입 내로잉 버그 2건도 이때 처음 +드러나 같이 고침(전에는 require가 안 뚫려 그 부분이 타입체크 자체를 안 +받고 있었음). + +**교훈**: CWD 기반이라는 첫 결론은 "여러 재현 케이스가 다 맞아떨어졌다"는 +확신 때문에 유지했는데, 실제로는 초기 가설(구조적 require 특수 케이스)을 +검증 안 하고 다른 잘못된 가설로 건너뛴 것 — 사용자가 정확한 1차 소스 +(공식 문서)를 제시해줘서 빠르게 정정됨. + +## 3. tbox 확인 → pesde 결정 → 실제 설치·검증 + +사용자 요청: `tbox`(`initreq/tbox`) 확인 후 "pesde로 가야 할 것 같다" +(dev-dependency 등 더 나은 툴링). `tbox`엔 pesde/wally 설정 자체가 없었지만 +(독립 스키마 라이브러리, 패키지 매니저 미사용), `src/init.luau`가 +`require("@self/...")` 패턴을 실제로 쓰고 있어 위 2번 정정을 교차 +확인해줬고, `.vscode/settings.json`이 `enableNewSolver: true`를 이미 +켜둔 것도 `HUMAN_TODO.md` 6번(에디터 솔버 확인)에 참고 근거로 남음. + +pesde 실물 설정은 `initreq/vide`(`pesde.toml`+`rokit.toml` 보유)를 +템플릿으로 씀. 이어서 사용자가 직접 pesde 공식 설치 문서 링크를 주고 +`/code/.local/bin`(이미 PATH)에 설치해보라고 요청 — GitHub 릴리스에서 +`pesde-0.7.3-linux-x86_64.zip`을 받아 압축 해제 후 그 경로에 배치, +`pesde 0.7.3` 확인(`rokit.toml`의 핀과 정확히 일치). + +**실제 `pesde install`을 워크스페이스 루트에서 돌려서 나온 것들**(전부 +`.claude/base/project-setup-plan.md`에 정리): +1. 패키지 이름에 하이픈 불가(`a-z`/`0-9`/`_`만) — `qwreey/quad-base`가 + 파싱 단계에서 거부됨(에러 메시지가 원인을 안 알려줘서 처음엔 의존성 + 선언 문법이 잘못된 줄 알았음). `quad_base`/`quad_roblox`로 고침. +2. `workspace = "scope/name"` 의존성 문법은 원래 손으로 쓴 그대로 + 맞았음(이름만 고치니 바로 통과) — `pesde add`는 워크스페이스 멤버를 + 못 찾는다는 것도 같이 확인(레지스트리 전용 커맨드). +3. `pesde.lock`은 워크스페이스 루트에 딱 하나만 생김. +4. **가장 중요한 발견** — 워크스페이스 의존성은 **심볼릭 링크**로 + 연결됨(`roblox_packages/.pesde/scope+pkg/version/pkg/src` → + 실제 형제 패키지 경로). 실제로 `quad_base`를 `quad-roblox`에서 + `require`하는 스모크 테스트를 짜보니 "could not resolve child + component 'src'"로 깨짐 — 직접 격리 재현(`/tmp`에 symlink 하나만 + 만들어 `require`) 후 원인이 **Luau의 require-by-string이 symlink를 + 의도적으로 안 따라간다**(보안상의 이유, RFC 검색으로 확인, 향후 + `.luaurc` opt-in 토글 가능성만 언급되고 아직 없음)로 확정. + `quad-roblox`가 실제로 `quad_base`를 쓰게 될 M5부터 이 문제가 + 현실화됨 — Rojo/Studio는 아마 무관(파일시스템 워크라 symlink를 그냥 + 따라갈 가능성이 높음)이지만 이 세션엔 Rojo가 없어 미검증. + +## 4. 산출물 + +- `.claude/base/project-setup-plan.md` 신설 — 위 내용 전부 정리, + "확인 완료/아직 확인 안 된 것" 절로 후속 검증 항목 명시. +- `.claude/base/architecture.md` "패키징 방식" 절 — wally→pesde 전환 반영. +- `.gitignore` — `roblox_packages/`/`luau_packages/`/`.pesde/` 추가. +- `pesde.toml`(루트+quad-base+quad-roblox), `rokit.toml`, `.luaurc`, + `default.project.json` 신설. +- `quad-base/src/{Relate.luau,init.luau,Debug/init.luau}`, + `quad-base/test/{mock.luau,smoke.mock.luau}` 신설. +- `luau-test/05`(다이아몬드 전파, 재작성 후 `done/`), `luau-test/21`(Store + 미선언 키, 신규) — 둘 다 `STATUS.md` 표 텍스트는 아직 안 고침(스스로 + 발견한 것 — 다음에 손댈 것). + +## 5. 다음 + +사용자가 이 세션 산출물(문서화 + 셋업 파일)만 먼저 커밋하길 원함 — +`luau-test` 스파이크 변경은 별도로 취급. 커밋 후 "지금 상황에서 검증 +가능한 것들"을 계속 검증하는 방향으로 이어감(구체 목록은 +`project-setup-plan.md`의 "아직 확인 안 됨" 절 + `todos.md`). diff --git a/.gitignore b/.gitignore index 726540f..dc8fe6a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,10 @@ # Python 바이트코드 — doc-check.py 실행 시 생김(32e9db0에 실수로 딸려 들어갔었음) __pycache__/ *.pyc + +# pesde 설치 산출물(각 패키지 로컬에 생김) — pesde.lock 커밋 여부는 아직 +# 미정(라이브러리 컨벤션 확인 필요, HUMAN_TODO 참고)이라 여기 안 넣음 +roblox_packages/ +luau_packages/ +lune_packages/ +.pesde/ diff --git a/.luaurc b/.luaurc new file mode 100644 index 0000000..e53b762 --- /dev/null +++ b/.luaurc @@ -0,0 +1,10 @@ +{ + "languageMode": "strict", + "lint": { + "*": true + }, + "aliases": { + "quad-base": "quad-base/src", + "quad-roblox": "quad-roblox/src" + } +} diff --git a/default.project.json b/default.project.json new file mode 100644 index 0000000..7b072e7 --- /dev/null +++ b/default.project.json @@ -0,0 +1,15 @@ +{ + "name": "quad", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "$className": "ReplicatedStorage", + "quad-base": { + "$path": "quad-base/src" + }, + "quad-roblox": { + "$path": "quad-roblox/src" + } + } + } +} diff --git a/pesde.lock b/pesde.lock new file mode 100644 index 0000000..b3d815c --- /dev/null +++ b/pesde.lock @@ -0,0 +1,12 @@ +# This file is automatically @generated by pesde. +# It is not intended for manual editing. +format = 2 +name = "qwreey/quad" +version = "0.0.0" +target = "roblox" + +[workspace."qwreey/quad_base"] +roblox = "quad-base" + +[workspace."qwreey/quad_roblox"] +roblox = "quad-roblox" diff --git a/pesde.toml b/pesde.toml new file mode 100644 index 0000000..259d81d --- /dev/null +++ b/pesde.toml @@ -0,0 +1,13 @@ +# 워크스페이스 루트 — 그 자체로는 게시되지 않음(`private = true`). +# `quad-base`/`quad-roblox`가 실제 게시 대상 워크스페이스 멤버. +# 구조는 `.claude/base/architecture.md` "구현 착수: 소스 트리 구조 확정" 절의 +# RbxUtil식 모노레포(루트 통합 개발, 서브패키지마다 독립 게시)를 그대로 따르되 +# 패키지 매니저만 wally -> pesde로 전환(사용자 결정, 2026-08-19 — dev-dependency +# 지원이 더 낫다는 판단). +name = "qwreey/quad" +version = "0.0.0" +private = true +workspace_members = ["quad-base", "quad-roblox"] + +[target] +environment = "roblox" diff --git a/quad-base/pesde.lock b/quad-base/pesde.lock new file mode 100644 index 0000000..d1ac3d6 --- /dev/null +++ b/quad-base/pesde.lock @@ -0,0 +1,6 @@ +# This file is automatically @generated by pesde. +# It is not intended for manual editing. +format = 2 +name = "qwreey/quad_base" +version = "0.0.0" +target = "roblox" diff --git a/quad-base/pesde.toml b/quad-base/pesde.toml new file mode 100644 index 0000000..ccfce6e --- /dev/null +++ b/quad-base/pesde.toml @@ -0,0 +1,9 @@ +name = "qwreey/quad_base" +version = "0.0.0" +description = "quad — 엔진 무관 코어: Store/State/Source 온톨로지, pluggable 디스패치 엔진" +includes = ["src/*"] + +[target] +environment = "roblox" +build_files = ["src"] +lib = "src/init.luau" diff --git a/quad-base/src/Debug/init.luau b/quad-base/src/Debug/init.luau new file mode 100644 index 0000000..c306760 --- /dev/null +++ b/quad-base/src/Debug/init.luau @@ -0,0 +1,25 @@ +--[[ + InitDebug(module) — `module.debug` 표면 설치. + `.claude/base/module-lifecycle-plan.md` "모듈 표면의 디버그 플래그" / + "New()의 내부 구성 — InitXxx 팩토리 체이닝" 절 그대로. + + 각 InitXxx가 따르는 멱등 가드 모양의 첫 실제 사례 — 파일 스코프 + `relate`(클로저 밖)로 "이 particular module 인스턴스에 이미 Init됐는가"를 + 기록한다. +]] + +local Relate = require("./Relate") + +local relate = Relate() -- 파일 스코프, 이 Init 전체가 공유하는 단 하나의 인스턴스 +local INITED = {} -- 이 파일 전용 유일 키(다른 InitXxx의 relate와 네임스페이스가 안 겹침) + +local function Init(module: any) + if relate:GetStrong(module, INITED) then + return -- 이미 이 module 인스턴스엔 Init됨, no-op + end + relate:SetStrong(module, INITED, true) -- 실제 작업 전에 먼저 표시(순환 의존 대비) + + module.debug = false +end + +return Init diff --git a/quad-base/src/Relate.luau b/quad-base/src/Relate.luau new file mode 100644 index 0000000..484ca6b --- /dev/null +++ b/quad-base/src/Relate.luau @@ -0,0 +1,92 @@ +--[[ + Relate — inst를 weak 키로 하는 범용 릴레이션 프리미티브. + `.claude/base/relate-plan.md` "API"/"실제 구조" 절 그대로 구현. +]] + +export type Relate = { + SetStrong: (self: Relate, inst: any, key: any, value: any) -> (), + GetStrong: (self: Relate, inst: any, key: any) -> any?, + SetWeak: (self: Relate, inst: any, key: any, value: any) -> (), + GetWeak: (self: Relate, inst: any, key: any) -> any?, +} + +type Bucket = { + StrongMap: { [any]: any }?, + WeakMap: { [any]: any }?, +} + +-- 모든 WeakMap이 공유하는 단일 메타테이블 — 매번 새로 만들 이유가 없음 +local WEAK_VALUE_MT = { __mode = "v" } + +local RelateImpl = {} +RelateImpl.__index = RelateImpl + +local function getBucket(self, inst: any): Bucket? + return self.buckets[inst] +end + +local function getOrCreateBucket(self, inst: any): Bucket + local bucket = self.buckets[inst] + if bucket == nil then + bucket = {} :: Bucket + self.buckets[inst] = bucket + end + return bucket +end + +function RelateImpl:SetStrong(inst: any, key: any, value: any) + local bucket = getOrCreateBucket(self, inst) + local strongMap = bucket.StrongMap + if strongMap == nil then + local newMap = {} + bucket.StrongMap = newMap + newMap[key] = value + return + end + strongMap[key] = value +end + +function RelateImpl:GetStrong(inst: any, key: any): any? + local bucket = getBucket(self, inst) + if bucket == nil then + return nil + end + local strongMap = bucket.StrongMap + if strongMap == nil then + return nil + end + return strongMap[key] +end + +function RelateImpl:SetWeak(inst: any, key: any, value: any) + local bucket = getOrCreateBucket(self, inst) + local weakMap = bucket.WeakMap + if weakMap == nil then + local newMap = setmetatable({}, WEAK_VALUE_MT) + bucket.WeakMap = newMap + newMap[key] = value + return + end + weakMap[key] = value +end + +function RelateImpl:GetWeak(inst: any, key: any): any? + local bucket = getBucket(self, inst) + if bucket == nil then + return nil + end + local weakMap = bucket.WeakMap + if weakMap == nil then + return nil + end + return weakMap[key] +end + +local function Relate(): Relate + local self = setmetatable({ + buckets = setmetatable({}, { __mode = "k" }) :: { [any]: Bucket }, + }, RelateImpl) + return (self :: any) :: Relate +end + +return Relate diff --git a/quad-base/src/init.luau b/quad-base/src/init.luau new file mode 100644 index 0000000..b7cba61 --- /dev/null +++ b/quad-base/src/init.luau @@ -0,0 +1,26 @@ +--[[ + quad-base 최상위 진입점. + `.claude/base/architecture.md` 확정 결정 13 / `.claude/base/module-lifecycle-plan.md` + "New()의 내부 구성 — InitXxx 팩토리 체이닝" 절 그대로. + + `require(quad-base)`는 이미 만들어진 기본 인스턴스 자체다 — 다중 + 인스턴스화가 필요한 드문 경우에만 반환값의 `.New()`를 명시적으로 호출. +]] + +local InitDebug = require("@self/Debug") + +type Quad = { + New: () -> Quad, + debug: boolean, +} + +local function New(): Quad + local module = { New = New } :: Quad + + InitDebug(module) + -- 서브시스템이 늘어날 때마다 이 자리에 InitXxx(module)를 순서 무관하게 추가 + + return module +end + +return New() diff --git a/quad-base/test/mock.luau b/quad-base/test/mock.luau new file mode 100644 index 0000000..a27945a --- /dev/null +++ b/quad-base/test/mock.luau @@ -0,0 +1,227 @@ +--[[ + quad-base용 최소 mock — Vide `test/mock.luau` 선례를 따르되 범위를 더 + 좁힘(`.claude/base/architecture.md` "테스트 전략" 절): parent/children + 트리 + 타입 검증 없는 property bag + property별 변경 시그널만. `IsA()`/ + 클래스별 프로퍼티 스키마/`WaitForChild`/`DataModel`은 만들지 않는다 — + quad-base 코어는 `inst`를 `any`로 취급하고 Instance 특정 동작을 참조하지 + 않으므로 mock이 실제 Roblox 충실도를 가질 이유가 없다(같은 절). + + Vide의 mock과 달리 GC-독립 userdata 프록시 트릭을 안 씀 — 그건 Roblox + 엔진 userdata의 GC 동일성을 흉내내려는 것이고, 그 동일성 문제는 + quad-roblox의 LifetimeHandle(gcconn 트릭)이 다루는 자리라 quad-base + 정적 스냅샷 테스트 범위 밖. +]] + +export type Signal = { + Connect: (self: Signal, fn: (...any) -> ()) -> Connection, + Fire: (self: Signal, ...any) -> (), +} + +export type Connection = { + Connected: boolean, + Disconnect: (self: Connection) -> (), +} + +local Signal = {} +Signal.__index = Signal + +local function newSignal(): Signal + return (setmetatable({ connections = {} }, Signal) :: any) :: Signal +end + +function Signal:Connect(fn: (...any) -> ()): Connection + local connections = (self :: any).connections + local conn = { Connected = true, fn = fn } + function conn.Disconnect(self2) + if not self2.Connected then + return + end + self2.Connected = false + local i = table.find(connections, conn) + if i then + table.remove(connections, i) + end + end + table.insert(connections, conn) + return (conn :: any) :: Connection +end + +function Signal:Fire(...: any) + -- 발화 도중 연결이 끊기는 경우를 대비해 스냅샷을 뜬 뒤 순회(Vide 선례) + local snapshot = table.clone((self :: any).connections) + for i = #snapshot, 1, -1 do + local conn = snapshot[i] + if conn.Connected then + conn.fn(...) + end + end +end + +export type MockInstance = { + ClassName: string, + Name: string, + Parent: MockInstance?, + Destroying: Signal, + GetPropertyChangedSignal: (self: MockInstance, property: string) -> Signal, + GetChildren: (self: MockInstance) -> { MockInstance }, + FindFirstChild: (self: MockInstance, name: string) -> MockInstance?, + Destroy: (self: MockInstance) -> (), + [string]: any, -- 타입 검증 없는 property bag +} + +type Data = { + className: string, + name: string, + parent: Data?, + children: { Data }, + changed: { [any]: Signal }, + properties: { [any]: any }, + destroying: Signal, +} + +local dataOf = setmetatable({}, { __mode = "k" }) :: { [any]: Data } +local proxyOf = setmetatable({}, { __mode = "v" }) :: { [Data]: any } + +local methods = {} + +local function fireChanged(data: Data, key: any) + local sig = data.changed[key] + if sig then + sig:Fire() + end +end + +local function getData(inst: any): Data + local data = dataOf[inst] + if data == nil then + error("not a mock instance", 2) + end + return data +end + +local function getProxy(data: Data): any + local existing = proxyOf[data] + if existing then + return existing + end + + local proxy = setmetatable({}, { + __index = function(_, key) + if methods[key] then + return methods[key] + elseif key == "Name" then + return data.name + elseif key == "ClassName" then + return data.className + elseif key == "Parent" then + if data.parent == nil then + return nil + end + return getProxy(data.parent) + elseif key == "Destroying" then + return data.destroying + else + return data.properties[key] + end + end, + __newindex = function(_, key, value) + if key == "Name" then + if type(value) ~= "string" then + error("Name must be a string", 2) + end + data.name = value + elseif key == "Parent" then + assert(value == nil or dataOf[value] ~= nil, "attempt to set non-instance as Parent") + if data.parent then + local siblings = data.parent.children + local i = table.find(siblings, data) + if i then + table.remove(siblings, i) + end + end + if value == nil then + data.parent = nil + else + local newParentData = getData(value) + data.parent = newParentData + table.insert(newParentData.children, data) + end + else + data.properties[key] = value + end + fireChanged(data, key) + end, + }) + + dataOf[proxy] = data + proxyOf[data] = proxy + return proxy +end + +local Instance = {} + +function Instance.new(className: string): MockInstance + local data: Data = { + className = className, + name = className, + parent = nil, + children = {}, + changed = {}, + properties = {}, + destroying = newSignal(), + } + return (getProxy(data) :: any) :: MockInstance +end + +function methods.GetPropertyChangedSignal(inst: any, property: string): Signal + local data = getData(inst) + local sig = data.changed[property] + if sig == nil then + sig = newSignal() + data.changed[property] = sig + end + return sig +end + +function methods.GetChildren(inst: any): { any } + local data = getData(inst) + local out = table.create(#data.children) + for i, childData in data.children do + out[i] = getProxy(childData) + end + return out +end + +function methods.FindFirstChild(inst: any, name: string): any? + local data = getData(inst) + for _, childData in data.children do + if childData.name == name then + return getProxy(childData) + end + end + return nil +end + +function methods.Destroy(inst: any) + local data = getData(inst) + data.destroying:Fire() + if data.parent then + local siblings = data.parent.children + local i = table.find(siblings, data) + if i then + table.remove(siblings, i) + end + data.parent = nil + fireChanged(data, "Parent") + end +end + +local function isMockInstance(value: any): boolean + return dataOf[value] ~= nil +end + +return { + Instance = Instance, + newSignal = newSignal, + isMockInstance = isMockInstance, +} diff --git a/quad-base/test/smoke.mock.luau b/quad-base/test/smoke.mock.luau new file mode 100644 index 0000000..d3a1fc4 --- /dev/null +++ b/quad-base/test/smoke.mock.luau @@ -0,0 +1,59 @@ +--[[ 임시 스모크 테스트 — mock.luau 자체가 기대대로 동작하는지 확인 ]] + +local mock = require("./mock") +local Instance = mock.Instance + +print("=== 1. property bag + changed signal ===") +do + local frame = Instance.new("Frame") + local fired = {} + frame:GetPropertyChangedSignal("Visible"):Connect(function() + table.insert(fired, frame.Visible) + end) + frame.Visible = false + frame.Visible = true + assert(#fired == 2 and fired[1] == false and fired[2] == true, "changed signal must fire per write with the new value visible") + print("PASS") +end + +print() +print("=== 2. parent/children tree ===") +do + local root = Instance.new("Frame") + local child1 = Instance.new("Frame") + local child2 = Instance.new("Frame") + child1.Name = "A" + child2.Name = "B" + child1.Parent = root + child2.Parent = root + + local children = root:GetChildren() + assert(#children == 2, "expected 2 children") + assert(root:FindFirstChild("A") == child1) + assert(root:FindFirstChild("B") == child2) + assert(child1.Parent == root) + + child1.Parent = nil + assert(#root:GetChildren() == 1, "removing Parent must detach from old parent's children") + print("PASS") +end + +print() +print("=== 3. Destroy fires Destroying + detaches ===") +do + local root = Instance.new("Frame") + local child = Instance.new("Frame") + child.Parent = root + + local destroyed = false + child.Destroying:Connect(function() + destroyed = true + end) + child:Destroy() + assert(destroyed, "Destroying must fire on Destroy") + assert(#root:GetChildren() == 0, "Destroy must detach from parent") + print("PASS") +end + +print() +print("=== ALL PASS ===") diff --git a/quad-roblox/pesde.lock b/quad-roblox/pesde.lock new file mode 100644 index 0000000..0bb744c --- /dev/null +++ b/quad-roblox/pesde.lock @@ -0,0 +1,13 @@ +# This file is automatically @generated by pesde. +# It is not intended for manual editing. +format = 2 +name = "qwreey/quad_roblox" +version = "0.0.0" +target = "roblox" + +[graph."qwreey/quad_base@0.0.0 roblox"] +direct = ["quad_base", { workspace = "qwreey/quad_base", version = "^" }, "standard"] + +[graph."qwreey/quad_base@0.0.0 roblox".pkg_ref] +ref_ty = "workspace" +path = "quad-base" diff --git a/quad-roblox/pesde.toml b/quad-roblox/pesde.toml new file mode 100644 index 0000000..e491418 --- /dev/null +++ b/quad-roblox/pesde.toml @@ -0,0 +1,12 @@ +name = "qwreey/quad_roblox" +version = "0.0.0" +description = "quad — Roblox 백엔드: quad-base의 pluggable 인터페이스를 실제로 구현" +includes = ["src/*"] + +[target] +environment = "roblox" +build_files = ["src"] +lib = "src/init.luau" + +[dependencies] +quad_base = { workspace = "qwreey/quad_base", version = "^" } diff --git a/rokit.toml b/rokit.toml new file mode 100644 index 0000000..f071adb --- /dev/null +++ b/rokit.toml @@ -0,0 +1,10 @@ +# Rokit-관리 툴체인. `vide`(`.claude/initreq/vide/rokit.toml`) 선례를 따르되 +# wally 항목은 뺌(pesde로 전환, 2026-08-19 사용자 결정). +# +# ⚠️ 버전 핀은 이 세션이 웹에서 확인한 시점 기준 — 이 샌드박스엔 rokit/pesde +# 바이너리가 없어 실제로 `rokit install`을 돌려 검증하지 못했다. 실제 +# 설치 전에 `rokit add pesde-pkg/pesde` / `rokit add rojo-rbx/rojo`로 +# 최신 태그를 직접 재확인할 것. +[tools] +pesde = "pesde-pkg/pesde@0.7.3+registry.0.2.3" +rojo = "rojo-rbx/rojo@7.7.0"