--[[ 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가 Lua 참조와 무관하게 회수·재생성되는 **엔진 GC 동일성**을 흉내내려는 것이고, 그건 quad-roblox의 `nativeClaim`이 실기기에서 다루는 자리라 mock 범위 밖. 반면 **생명주기 계약 로직**(`bindLifetime`/`canBound`/ `canExecute`/`unbindLifetime`이 gcconn의 `.Connected`로 판정하는 것)은 mock 시그널 위에서 그대로 재현한다 — 아래 `installLifetime`. **[2026-08-28 M2, ROADMAP `H-97`] `installLifetime(quad)`** — 생명주기 4종 (`bindLifetime`/`unbindLifetime`/`canBound`/`canExecute`)의 mock 백엔드. quad-roblox가 할 모듈 뮤테이션을 그대로 흉내내며, 본문은 `.claude/base/lifecycle-pattern.md` (0)/(1)의 실 구현 스케치를 그대로 옮긴 것(gcconn = 절대 안 발화하는 `ClassName` 변경 시그널의 Connection, gchold = inst에 매달린 값들의 강참조 홀더, `InstData`/`BindData` 두 Relate). 이걸 위해 `Destroy`가 Roblox처럼 동작한다 — Destroying 발화 → Parent nil → **자손까지 Destroy** → **그 인스턴스의 모든 Connection을 끊는다**(`Connected = false`), 두 번째 Destroy는 no-op. gcconn의 `.Connected`가 곧 생존 판정이라서. Roblox와 다른 점 하나: gcconn/gchold 셋업(`nativeClaim`)은 quad가 Instance를 만들 때 하는 일인데 mock Instance는 quad 밖에서 만들어지므로 여기선 `bindLifetime` 첫 호출에 lazy로 한다 — quad-roblox는 그러면 안 된다 (userdata 동일성 구멍, 같은 문서 (0) 절). ]] export type Signal = { Connect: (self: Signal, fn: (...any) -> ()) -> Connection, Fire: (self: Signal, ...any) -> (), DisconnectAll: (self: Signal) -> (), } 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 -- Destroy가 부른다 — Roblox는 Destroy 시 그 인스턴스의 모든 연결을 끊는다. function Signal:DisconnectAll() local connections = (self :: any).connections for i = #connections, 1, -1 do connections[i].Connected = false connections[i] = nil end 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, destroyed: boolean, } 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(), destroyed = false, } 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) if data.destroyed then return -- Roblox처럼 두 번째 Destroy는 no-op (Destroying 핸들러 안의 재귀 Destroy 포함) end data.destroyed = true -- Roblox 순서: Destroying 발화 → Parent = nil(변경 시그널 관측 가능) → 자손 Destroy -- → 이 인스턴스의 모든 연결 해제. gcconn(`ClassName` 변경 시그널)의 `.Connected`가 -- 생존 판정의 근거라 마지막 단계가 곧 canExecute의 전환이다. 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 for _, childData in table.clone(data.children) do methods.Destroy(getProxy(childData)) end data.destroying:DisconnectAll() for _, sig in data.changed do sig:DisconnectAll() end end local function isMockInstance(value: any): boolean return dataOf[value] ~= nil end --[[ installLifetime(quad) — `.claude/base/lifecycle-pattern.md` (0)/(1) 스케치를 mock 시그널 위에 그대로 옮김. quad 모듈 인스턴스를 뮤테이션한다(quad-roblox와 같은 경로). 반환값은 없고, 같은 quad에 두 번 부르면 두 번째는 무시된다. ]] local Relate = require("../src/Relate") local installed = setmetatable({}, { __mode = "k" }) :: { [any]: true } local function installLifetime(quad: any) if installed[quad] then return end installed[quad] = true local isObserver, isEffect = quad.isObserver, quad.isEffect local InstData = Relate() -- inst -> gchold/gcconn ((0)에서 채움) local BindData = Relate() -- value -> gchold/gcconn (bindLifetime이 채움) -- `false or`: local 함수가 상수 접힘/인라인되면 아래 클로저의 업밸류 캡처가 사라진다 local nop = (false or function(...: any) end) :: (...any) -> () -- (0) nativeClaim 상당 — mock에선 lazy(헤더 주석 참고) local function claim(inst: any) if InstData:GetWeak(inst, "gchold") ~= nil then return end local gchold = {} local gcconn = inst:GetPropertyChangedSignal("ClassName"):Connect(function() nop(gchold, inst) -- 절대 발화 안 함. 클로저가 gchold와 inst를 업밸류로 붙잡는 게 전부 end) gchold[1] = gcconn -- 배열 자리 1번은 gcconn 전용(값들은 해시 자리에) if getData(inst).destroyed then -- lazy claim의 구멍(리뷰 `H-171`): 이미 Destroy된 인스턴스의 옛 gcconn은 연결이 -- 끊겨 GC되므로 여기로 다시 들어오는데, 새 gcconn이 Connected=true면 그 값은 -- 영원히 산다. Roblox에선 nativeClaim이 생성 시 1회라 이 경로 자체가 없다 — -- 죽은 인스턴스에 묶인 값은 즉시 죽은 상태로 시작하게 맞춘다. gcconn:Disconnect() end InstData:SetWeak(inst, "gchold", gchold) InstData:SetWeak(inst, "gcconn", gcconn) end -- 비공개 — canBound/canExecute가 공유하는 실제 판정. local function isBoundAlive(value: any): boolean -- (a) inst-scoped 경로: bindLifetime이 복사해둔 gcconn을 value 자신에게서 찾음 local gcconn = BindData:GetWeak(value, "gcconn") if gcconn ~= nil and gcconn.Connected then return true end -- (b) 전역 경로: 구독 경로(강/약)가 세운 것. Observer/Effect에만 있는 필드. if isObserver(value) or isEffect(value) then return value.Subscribed == true end return false end local function canBound(value: any): boolean return not isBoundAlive(value) end local function canExecute(value: any): boolean return isBoundAlive(value) end local function bindLifetime(inst: any, value: any) if not isMockInstance(inst) then error("bindLifetime: inst is not a mock instance", 2) end if not canBound(value) then -- 어느 경로로 묶여있는지만 메시지에 실어줌. `.Subscribed`를 무조건 -- 인덱싱하면 안 됨 — value가 평범한 클로저일 수도 있음. local isGlobal = isObserver(value) or isEffect(value) if isGlobal then isGlobal = value.Subscribed == true end error( if isGlobal then "bindLifetime: value is already subscribed" else "bindLifetime: value is already bound to another Instance", 2 ) end claim(inst) -- TODO(H-184): the binding is committed below BEFORE `_bindDestroying` runs its `isRunning` -- guard; if that guard throws, the Effect stays bound with no Destroying connection -- (order transcribed from lifecycle-pattern.md (1) — quad-roblox inherits it). local gchold = InstData:GetWeak(inst, "gchold") gchold[value] = true -- 강참조: inst가 사는 동안 value 생존 보장(계약 1) -- value가 자기 홀더/생존 판정 근거를 직접 들고 있게 함(계약 2). 둘 다 weak. BindData:SetWeak(value, "gchold", gchold) BindData:SetWeak(value, "gcconn", InstData:GetWeak(inst, "gcconn")) if isObserver(value) then value:_catchUp() -- [H-159] 묶이기 전 홀드된 emit 1회 (단위 3에서 합류) end if isEffect(value) then value:_bindDestroying(inst) -- Destroying 연결 + 홀드 캐치업 1회 (단위 3) end end local function unbindLifetime(value: any) -- bind의 대칭 — cleanup은 부르지 않고, Ref 콜백/내부 Observer도 안 뗀다. if isEffect(value) then value:_unbindDestroying() -- Destroying 연결만 끊는다 (단위 3) end local gchold = BindData:GetWeak(value, "gchold") if gchold then gchold[value] = nil -- inst는 안 건드림, 이 value 하나만 조기 해제 end BindData:SetWeak(value, "gchold", nil) BindData:SetWeak(value, "gcconn", nil) end -- 주입 op `onDestroying(inst, fn): Connection` — quad-roblox는 `inst.Destroying:Connect(fn)` -- 한 줄(`architecture.md` EngineOps). mock은 그 시그널을 그대로 쓴다. local function onDestroying(inst: any, fn: () -> ()): any if not isMockInstance(inst) then error("onDestroying: inst is not a mock instance", 2) end return inst.Destroying:Connect(fn) end quad.bindLifetime = bindLifetime quad.unbindLifetime = unbindLifetime quad.canBound = canBound quad.canExecute = canExecute quad.onDestroying = onDestroying end return { Instance = Instance, newSignal = newSignal, isMockInstance = isMockInstance, installLifetime = installLifetime, }