--[[ State — derived reactive node (`:Compute` / `:With` / `:Apply`), the propagation loop, and the epoch-based recompute judgement. Assembly (`H-174`, user-confirmed 2026-08-28): `State.Init(module)` builds one implementation table per `quad` instance and keeps `module` in its closure, so later units (`Observer`, `Gate`) can read lifetime gates as `module.canExecute(...)` AT FIRE TIME (never captured at init — the backend overwrites those fields after `New()`). `implFor(module)` hands the same table to `Source.luau`'s init (its only caller — `Store.luau` never touches it; it reaches State only through the `Source` values it creates). There is no runtime `State` constructor — nodes exist only as results of `:Compute`/`:With` (and `:Gate`, unit 4). `State` is NOT an `Epoch` (`state-epoch-plan.md` §4): its counters are private recompute bookkeeping, and upstream tracking goes through `valueEpochMap`. Sources, transcribed as-is: - `.claude/base/source-state-plan.md` "전파 루프 — 확정 의사코드" (`_emitDown`: snapshot, then `sub:_receive(from)` — every subscriber is an `EmitReceive`, `H-163`), "`:With`도 새 State 노드로 확정" (pass-through node, wider subscription list), "self 인자도 lazy 핸들로 통일", "`:Compute(fn, ...)`" (trailing deps: one node), "trailing deps를 `fn`에 lazy positional 인자로도 노출" (`fn(self, previous?, ...deps)`), "`state:Apply(factory)`" (`__apply` method form, `H-158`), "`_hold`로 살아남는다" (downstream → upstream strong, upstream → downstream weak). - `.claude/base/state-epoch-plan.md` §4: seeding (`emitEpochMap` empty, `valueEpochMap` filled from every dep — `:Sync(dep)` if the dep is an `Epoch`, `:TrackFrom(dep.valueEpochMap)` otherwise), receive rules 1~3, the `cacheTargetCount`/`cacheCurrCount` pair (`H-85`, `curr = nil` initially), recompute (`d:_track`, then `cacheCurrCount = gen` only on success), `Get` (`Refresh` walk advances the value only — never notifies). - `.claude/base/modifier-plan.md` 7번: a `:Compute` result that is a Modifier errors before it is cached. - `.claude/base/gate-plan.md` "`GateNode` 조립" (unit 4): `state:Gate(setup)` makes a `GateNode` — a pass-through State node whose `_receive` judges with `Peek` on the emit map, merges the source into a weak-key `_withheld` set, and hands control to the policy; `_flush(commit)` is the `emit` the policy holds (empty → false; swap → `Sync(batch)` → `_emitDown(batch)`; `emit(false)` discards). Value is never gated (3번). ]] local Brand = require("./Brand") local EpochMap = require("./EpochMap") local ImplRegistry = require("./ImplRegistry") local Observer = require("./Observer") local Void = require("./Void") local QuadTypes = require("../roblox_packages/quad_types") export type StateData = QuadTypes.StateData export type State = QuadTypes.State type EpochMap = EpochMap.EpochMap local StateBrand = Brand.StateBrand local isEpoch = Brand.isEpoch local isState = Brand.isState local isModifier = Brand.isModifier local WEAK_KEY_MT = { __mode = "k" } -- One implementation per quad instance (`H-174`); storage scheme in `ImplRegistry.luau`. local implsOf = ImplRegistry.implsOf local function createImpl(module: any) module:RunInit(Observer.Init) -- `:Observer` needs the per-instance Observer impl (idempotent pull, `H-177`) local ObserverImpl = Observer.implFor(module) local Impl = {} Impl.__index = Impl -- ── propagation loop ────────────────────────────────────────────── -- Shared by every node type that has `_subs` (Source nodes too). local function emitDown(self: any, from: any) -- H-23: adding a key during iteration is undefined, so snapshot first. -- "이번 파동 중에 붙은 구독자는 다음 파동부터"가 계약. local snap = {} for sub in self._subs do snap[#snap + 1] = sub end for _, sub in snap do (sub :: any):_receive(from) -- every subscriber is an `EmitReceive` (`H-163`) end end Impl._emitDown = emitDown -- ── node creation ───────────────────────────────────────────────── local function newNode(deps: { any }, fn: any, mt: any?): any for i, dep in deps do if not isState(dep) then error(`State: dep #{i} is not a State/Source`, 3) -- 3: past Compute/With/Gate to the user's call end end local self = setmetatable({ _fn = fn, _hold = deps, -- strong: downstream → upstream (`_hold` invariant) _subs = setmetatable({}, WEAK_KEY_MT), -- weak-key: upstream → downstream _valueEpochMap = EpochMap(), _emitEpochMap = EpochMap(), -- starts EMPTY on purpose (§4 seeding) _cacheTargetCount = 0, _cacheCurrCount = nil, -- never computed yet → always differs _cache = nil, }, mt or Impl) StateBrand:register(self) -- `H-152`: a GateNode is a State too — first line of its assembly -- §4 seeding: valueEpochMap takes every upstream epoch, live. for _, dep in deps do dep._subs[self] = true if isEpoch(dep) then self._valueEpochMap:Sync(dep) else self._valueEpochMap:TrackFrom(dep._valueEpochMap) end end return self end Impl._newNode = newNode -- ── EmitReceive + epoch bookkeeping ────────────────────────────── function Impl._track(self: any, map: EpochMap) map:TrackFrom(self._valueEpochMap) -- a State is not an Epoch: hand over what it tracks end function Impl._invalidate(self: any) self._cacheTargetCount = bit32.bnot(-self._cacheTargetCount) end -- §4 "emit을 받았을 때": two maps, two booleans, three cases. function Impl._receive(self: any, from: any) local valueChanged = self._valueEpochMap:Update(from) local emitChanged = self._emitEpochMap:Update(from) if valueChanged then self:_invalidate() -- rule 1: value is stale end if valueChanged or emitChanged then emitDown(self, from) -- rules 1/2: forward the SAME source; rule 3: swallow end end function Impl._recompute(self: any) local gen = self._cacheTargetCount -- snapshot right before fn (`H-85`) local hold = self._hold -- `H-198` (user-confirmed 2026-08-31): stamp upstream revisions BEFORE fn — -- anything that moves DURING fn (a reentrant Set, or one withheld by a -- closed gate, which never reaches our `_receive`) then shows up afterwards -- as `Refresh` drift and `Get`'s loop recomputes. Stamping after fn marked -- those moves as already seen (permanent stale cache). for _, dep in hold do dep:_track(self._valueEpochMap) end -- `fn(self, previous?, ...deps)` — `self` is the RECEIVER's lazy handle -- (`hold[1]`), never this result node. local result = self._fn(hold[1], self._cache, table.unpack(hold, 2)) if isModifier(result) then -- `H-205`: level 3 — `_recompute`'s only callers are Get's own lines, so 2 -- always blamed quad internals; 3 reaches the user on a direct `Get` and is -- never worse through a pass-through chain (no fixed level reaches user code there). error("State: a Compute function returned a Modifier — State/Source cannot hold Modifiers", 3) end self._cache = result self._cacheCurrCount = gen -- only on success: a thrown fn never marks the cache valid end function Impl.Get(self: any): any -- `H-198` (user-confirmed): a LOOP, not a single pass — restart until one -- recompute completes with nothing moving under it, so every Get returns a -- value computed from the latest reads ("항상 최신 값만 읽게되어"). Counter -- mismatch catches Sets our `_receive` saw (`H-85`); `Refresh` drift catches -- the ones it never did (behind a closed gate). Values only — notification -- still waits for the real emit. An fn that re-Sets its own (transitive) -- dep on every pass never converges: UB (user-confirmed). while true do if self._cacheCurrCount ~= self._cacheTargetCount then self:_recompute() elseif self._valueEpochMap:Refresh() then self:_invalidate() self:_recompute() else return self._cache end end end -- ── derived-node methods (shared by Source through `__index`) ───── local function passThrough(s: any) return s:Get() end -- `H-199`: a nil dep must error, not vanish — `deps[#deps + 1]` would drop it -- silently AND shift later deps left, so `fn`'s positional lazy-dep args land -- in the wrong slots. Index numbering matches `newNode`'s (self is dep #1). local function collectDeps(self: any, ...: any): { any } local deps = { self } for i = 1, select("#", ...) do local dep = (select(i, ...)) if dep == nil then error(`State: dep #{i + 1} is nil`, 3) -- 3: past With/Compute to the user's call end deps[i + 1] = dep end return deps end function Impl.With(self: any, ...: any): any return newNode(collectDeps(self, ...), passThrough) -- value is `self`'s, subscriptions are wider end function Impl.Compute(self: any, fn: any, ...: any): any if type(fn) ~= "function" then -- `H-202`: without this the crash lands at the first `Get` deep inside -- `_recompute` — sibling surfaces (Gate/Observer/Apply) all validate here. error("State: Compute fn must be a function", 2) end return newNode(collectDeps(self, ...), fn) -- one node: edges only, no combining node end -- ── GateNode (`gate-plan.md` "`GateNode` 조립") — same layer as a ComputeNode ── local GateImpl = setmetatable({}, { __index = Impl }) GateImpl.__index = GateImpl local function newWithheld() return setmetatable({}, WEAK_KEY_MT) -- `H-9`: every withheld table is weak-keyed, the swapped-in one too end -- The ONLY exception to §4: the emit map is Peeked at receive time and -- written at flush (`Sync(batch)`), so "what I have sent downstream" stays true. function GateImpl._receive(self: any, from: any) local valueChanged = self._valueEpochMap:Update(from) local emitChanged = self._emitEpochMap:Peek(from) -- Peek, not Update (`H-72`) if valueChanged then self:_invalidate() end if not (valueChanged or emitChanged) then return -- rule 3: swallowed — no policy, not added to the set end -- Merge the source(s) into the withheld set. A batch is UNFOLDED (never held -- by reference — an upstream gate's batch is a one-shot snapshot). local withheld = self._withheld if isEpoch(from) then withheld[from] = true else for epoch in from do withheld[epoch] = true end end self._onUpstreamEmit() -- the policy decides: emit() / emit(false) / nothing end -- This IS the `emit(commit) -> boolean` the policy holds. function GateImpl._flush(self: any, commit: boolean?): boolean local batch = self._withheld if next(batch) == nil then return false -- (1) empty batch: nothing at all (8번) end self._withheld = newWithheld() -- (2) swap, never clear — nested waves get a fresh table if commit == false then return true -- discard (`H-55`): no Sync, no propagation end self._emitEpochMap:Sync(batch) -- (3) BEFORE propagating, all at once emitDown(self, batch) -- (4) the detached batch is the payload — the gate itself never is return true end -- `state:Gate(setup)`; `setup(emit) -> onUpstreamEmit`. Returns a State (same T). function Impl.Gate(self: any, setup: any): any if type(setup) ~= "function" then error("State: Gate setup must be a function (emit) -> onUpstreamEmit", 2) end local node = newNode({ self }, passThrough, GateImpl) -- StateBrand + seeding + `_hold` like any node node._withheld = newWithheld() node._onUpstreamEmit = Void -- until setup returns: a throwing setup must not leave a nil callee -- `H-200` (b): detach while user code (`setup`) runs — whether it THROWS or -- returns garbage, no zombie subscriber is left behind (no pcall needed). The -- window is unobservable: the withheld set stays empty while detached. self._subs[node] = nil local onUpstreamEmit = (setup :: any)(function(commit: boolean?): boolean return node:_flush(commit) end) if type(onUpstreamEmit) ~= "function" then error("State: Gate setup must return the onUpstreamEmit function", 2) end node._onUpstreamEmit = onUpstreamEmit -- strong: the policy closure (and, through it, the Blocker handle) self._subs[node] = true -- re-attach: the gate starts hearing emits only now return node end -- `state:Observer(fn?)` — leaf subscriber, fires once at registration -- (`source-state-plan.md` "`state:Observer(fn)`"). Body lives in `Observer.luau`. function Impl.Observer(self: any, fn: any?): any if fn ~= nil and type(fn) ~= "function" then error("State: Observer fn must be a function (or nil for the always-observe utility)", 2) end return ObserverImpl.new(self, fn) end function Impl.Apply(self: any, factory: any): any if type(factory) == "function" then return (factory :: any)(self) end if type(factory) ~= "table" or type(factory.__apply) ~= "function" then error("State: Apply factory must be a function or an object with an __apply method", 2) end return (factory :: any):__apply(self) -- method form: `self` there is the factory object (`H-158`) end return Impl end local function Init(module: any) implsOf(module).State = createImpl(module) end local function implFor(module: any) local impl = implsOf(module).State if impl == nil then error("State: State.Init(module) has not run for this quad instance", 1) -- invariant check; every caller pulls it in first (`H-177`) end return impl end return { Init = Init, implFor = implFor, }