Repository navigation
Schema.bind takes the game's root, always - #221
Merged
Merged
Conversation
A game bound without a root was branded by its schema type, which is covariant: a game and one whose schema is a superset were interchangeable, so a system, schedule, query, or entity id of one compiled against the other. The unrooted overloads are gone; a call without a root fails with a message that names the fix. Entity.isHandle takes a root, not a schema. defineRoot says why a root's name must be unique in the program: two roots of one name are one type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The mechanical part, by a script kept out of the repo (it is in the pull request): each unrooted call gets the file's root, declared once before the statement that first needs it and named after the file's descriptor prefix. A second game bound in the same scope gets its own root, so tests that keep two games apart still do. Types spelled with the schema as the root name typeof Root. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JSDoc examples that bind their own game, the type bench's generator, the consumer programs pack-check writes, SETUP's merge for naming the schema, types whose root argument sits on its own line, Entity.isHandle(Game.schema), and the expected messages of tests whose other game now fails on its root. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Schema.bindtakes the game's root, always. The unrooted overloads are gone.Why
Without a root, a game was branded by its schema type, which is covariant. A game and a game whose schema is a superset were interchangeable:
rb.tick(GA.Schedule(SystemOfA))compiled and ran a system that spawned and queriedVelocityinside a runtime with noVelocity, andqueries.q.get(idOfGameA)compiled in a system of the other game. The JSDoc onbindsaid games "cannot be connected accidentally", which was false for that form.packages/core/README.mdalso showed the unrooted form whileSETUP.mdtaught the rooted one: two ways to do one job, one of them unsafe.API
Schema.bind(fragment, Root)andSchema.bind(Core, Combat, Root). The root is aconst Rootinferred from the last argument and checked byRootArgument<Root>. A call without one fails, and the message names the fix:Property '"Schema.bind takes the game's root last: const Root = Schema.defineRoot('Game'), then Schema.bind(...fragments, Root)"' is missing ...This works for
bind(A)(a third signature that no value matches, needed because a one-argument call matches no rooted signature by arity), forbind(A, B), and forbind(A, B, A). The implementation throws the same message when types are erased.Schema.Schema.Game<S, Root>no longer defaultsRoottoS.Entity.isHandleconstrains its root toRootToken, so the unrooted way of naming a root,Entity.isHandle(Game.schema), no longer compiles.bindJSDoc describes one form. ThedefineRootJSDoc gives the guarantee and the rule below.packages/core/dtslint/Roots.tst.ts. It covers the inferred type of both forms, the missing-root message for each call shape, the snippet above with roots for each kind of value,isHandle, and same-named roots. The fix entry is in.changeset/root-mandatory.md, with diffs.What a root guarantees, and same-named roots
Games with different roots do not mix. Schedules, systems, runtimes, queries, entity ids, handles, and conditions or machines of one game are each rejected by the other, with
Type 'RootToken<"Roots/A">' is not assignable to type 'RootToken<"Roots/B">'. The snippet from the issue, with roots, fails at every line.Same-named roots:
RootToken<Name>is structural, so twodefineRoot("Game")calls make the same type, whether they are in one module or in two. Games bound under them mix exactly as unrooted games did; I checked thatrs2.tick(S1.Schedule(SystemOfS1))andqueries.q.get(idOfS1)compile. This cannot be closed in types with the currentdefineRoot(name). A call returns the same type for the same argument, and TypeScript is nominal only for declarations: aunique symbolfrom aconst, or class private members. Closing it would mean changing how roots are declared, for exampleconst GameKey = Symbol("Game")and thenSchema.defineRoot(GameKey), which touches every root in every game. That decision is the maintainer's, so instead thedefineRootJSDoc states the rule: "Declare one root per game, under a name no other game in the program uses ... Share the oneRootconstant instead of declaring it again." A type test pins the behaviour:expect(Second).type.toBe<typeof First>(). A runtime check is also not possible as things stand:makeGameignores the root at runtime, and a runtime does not know which game a schedule came from.Conversion
typeof Root."Storage/Position"becomesdefineRoot("Storage")), and falls back to the file name. Doc blocks use the block's prefix (Arena,Example) orGame.// ---cut---,// @errors).const Other = Schema.bind(...)) gets its own root,OtherRoot = defineRoot("<Prefix>/Other"). Otherwise the tests that keep two games apart would stop testing anything: about a dozen@ts-expect-errors went unused when every game in a file shared one root.@ts-expect-errorabout the root, and the comments in JSDoc. Nothing is left declaring a root it does not use: every declared root is passed to a bind.@exampleblocks that bind their own game, andQuery.ts's twoEntityRef<typeof Game.schema, typeof Game.schema, ...>examples;pack-check.tswrites from strings, and the merge comment inSETUP.mdstep 4 (nowSchema.bind(Core, Combat, Root).schema, withconst Rootdeclared). The same fix went into the unreleased.changeset/name-the-schema.md, so the changelog shows code that compiles;Entity.isHandle(Game.schema);StateMachine.tst.tsandCondition.tst.ts: the other game's values now fail on the root, before any schema difference;type-safety.mdxexpects2345 2769for its duplicate-name snippet. A rooted one-fragment call matches both the one- and the many-fragment signatures, so a schema error there is "No overload matches", as it already was for rooted games on main. I tried a many-fragment signature that needs three arguments, but TypeScript does not count a generic rest tuple's minimum length, and that version lost the missing-root message forbind(A, B). I reverted it.pauseWhenorGame.Command.entrycall sites.RenderSync.tschanges only in its example.origin/mainbefore pushing, through Netcode: inputs stay in their epoch, hides survive loss, a restarted seat ends the match #218, RenderSync makes a node again when its renderable changes #219, and Core: pauseWhile pauses while a condition holds; entries are exact tuples and Command.entry is gone #220. Core: pauseWhile pauses while a condition holds; entries are exact tuples and Command.entry is gone #220 conflicted inCommandEntries.tst.tsonly; I resolved it by taking main's file and re-running the script on it (one call). After that, a run over the whole repo finds 0 calls left.To convert files that land on main after this branch, run the script below from anywhere:
node --import tsx root-mandatory.ts <repo> [files...]. The hand edits are listed further down and only apply once.root-mandatory.tsby-hand.py(the edits the script leaves, as applied here)Verification (local, on the rebased branch)
pnpm run check: passes. That is typecheck, lint, type tests on TS 5.9.3 and 6.0.3 (1160 passed), 149 examples, and 1929 vitest tests in 168 files. In an earlier run under load (load average ~23),packages/tools/test/music.test.ts"plays every instrument in tune" timed out at 5 s. This PR does not touch that package, and the test passes alone (46/46).pnpm --dir website build: passes.pnpm pack:check: passes.pnpm bench:check: "No blocking performance regressions". Type metrics went down:origin/mainat Core: pauseWhile pauses while a condition holds; entries are exact tuples and Command.entry is gone #220 (measured here)Against the recorded baseline, the branch is +2.9% types and +1.6% instantiations: main itself moved there with Core: pauseWhile pauses while a condition holds; entries are exact tuples and Command.entry is gone #220, and both are within the tolerance.
Part of the drop comes from the stress program itself: its root is now a small
RootToken<"Stress">instead of the whole schema type. The firstbench:checkrun, at load average ~23, flagged five runtime cases: snapshot json-roundtrip, checkpoint take-tick, and three render/sync cases. No runtime code changed, so I compared those cases againstorigin/mainon the same machine:-2.1%,+0.2%,-4.1%,-2.6%, and+2.4%, with identical allocations. The re-run alone passed. I did not runpnpm bench:update.Left out, and why
Root = unknowndefaults on about 40 types (Query.Any<Root = unknown>,Entity.EntityId<S, Root = unknown>,Machine.Condition<Root = unknown>, ...). The unrooted form did not use them: it set the root toS. They mean "any root" in generic positions, and removing them is a wide refactor outside this change.Schema.Game'sRootis not constrained toRootToken. The pluginsInputCapture.system,Clock.system, andRenderSync.*take aSchema.Game<S, Root>with an unconstrainedRoot, andRenderSyncis being changed in another PR."Runtime"in five runtime test files, or"Data"in a type test and a runtime test. They never meet, so this is harmless, but strictly it goes against the rule now in the JSDoc.Bug found, not fixed
packages/core/bench/types/generate.tsis out of sync with the committedstress.tson main.pnpm bench:types:generatewrites the older API: tuple states["Menu", ...],nextMachines.flow.set("Paused"), andGame.Runtime.make(...)without.value. I updated the generator'sbindlines to match the convertedstress.ts, and did not regenerate.🤖 Generated with Claude Code