From 9df303294939fddc71418f00df0d73b5645dba0b Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Thu, 12 Nov 2026 08:44:09 -0700 Subject: [PATCH] templates: float the engine with a floor, keep the tool pinned A kit scaffolded today installed whatever engine was current when create-threenative was last published, because every template pinned the workspace version exactly. A lib patch reached new projects only after a full CLI repack, and there was no way to publish the two together. Templates now ship `>=0.3.13` for `@threenative/*` and keep create-threenative exact: pnpm resolves the newest published engine at install, so a release never has to touch a template again. A game already scaffolded stays frozen by its own pnpm-lock.yaml. - scripts/version-range.ts owns the specifier form; the pin gate, the release preflight and the release's pin writer all read it. - assertEnginePair accepts any core at or after the paired release instead of exact equality, so a newer core is no longer a refusal on a correct install. assertMcpShims and the engine's tn_version_handshake still fail closed on a pair that cannot work. - check-publish-state resolves a range's floor, the release the kit was tested against, instead of handing the registry an unreadable range. --- .../__tests__/core-pair.spec.ts | 42 +++++++++++ .../__tests__/doctor-fix.spec.ts | 12 ++++ .../__tests__/scaffold.spec.ts | 32 ++++----- packages/create-threenative/src/core-pair.ts | 38 ++++++++++ packages/create-threenative/src/index.ts | 14 ++-- .../templates/action-rpg/package.json | 12 ++-- .../templates/adventure/package.json | 12 ++-- .../templates/collectathon/package.json | 12 ++-- .../templates/kart/package.json | 10 +-- .../templates/minimal/package.json | 10 +-- .../templates/platformer/package.json | 12 ++-- .../templates/puzzle/package.json | 12 ++-- .../templates/racing/package.json | 12 ++-- .../templates/rain/package.json | 10 +-- .../templates/rts/package.json | 10 +-- .../templates/runner/package.json | 12 ++-- .../templates/sailing/package.json | 12 ++-- .../templates/shooter/package.json | 12 ++-- .../templates/snow/package.json | 12 ++-- .../templates/starter/package.json | 12 ++-- .../templates/tower-defense/package.json | 12 ++-- scripts/__tests__/check-publish-state.spec.ts | 17 +++++ scripts/__tests__/check-version-pins.spec.ts | 71 ++++++++++++++++--- scripts/__tests__/release.spec.ts | 24 +++++++ scripts/__tests__/version-range.spec.ts | 46 ++++++++++++ scripts/check-publish-state.ts | 9 ++- scripts/check-version-pins.ts | 6 +- scripts/prepare-release.ts | 10 ++- scripts/release.ts | 4 +- scripts/version-range.ts | 61 ++++++++++++++++ 30 files changed, 438 insertions(+), 132 deletions(-) create mode 100644 packages/create-threenative/__tests__/core-pair.spec.ts create mode 100644 packages/create-threenative/src/core-pair.ts create mode 100644 scripts/__tests__/version-range.spec.ts create mode 100644 scripts/version-range.ts diff --git a/packages/create-threenative/__tests__/core-pair.spec.ts b/packages/create-threenative/__tests__/core-pair.spec.ts new file mode 100644 index 0000000000..caba17a9a0 --- /dev/null +++ b/packages/create-threenative/__tests__/core-pair.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { CORE_TEMPLATE_FLOOR, pairsWithCore } from "../src/core-pair.js"; +import { CORE_PACKAGE_VERSION } from "../src/mcp-servers.js"; + +const [MAJOR, MINOR, PATCH] = CORE_PACKAGE_VERSION.split(".").map(Number) as [ + number, + number, + number, +]; + +function previousRelease(): string { + if (PATCH > 0) return `${MAJOR}.${MINOR}.${PATCH - 1}`; + if (MINOR > 0) return `${MAJOR}.${MINOR - 1}.99`; + return `${MAJOR - 1}.99.99`; +} + +describe("the core a game may pair with", () => { + it("ships the same floor the templates declare", () => { + expect(CORE_TEMPLATE_FLOOR).toBe(`>=${CORE_PACKAGE_VERSION}`); + }); + + it("accepts the release this create-threenative was built against", () => { + expect(pairsWithCore(CORE_PACKAGE_VERSION)).toBe(true); + }); + + it("accepts anything newer, because a template resolves the newest published engine", () => { + expect(pairsWithCore(`${MAJOR}.${MINOR}.${PATCH + 1}`)).toBe(true); + expect(pairsWithCore(`${MAJOR}.${MINOR + 1}.0`)).toBe(true); + expect(pairsWithCore(`${MAJOR + 1}.0.0`)).toBe(true); + }); + + it("refuses an install older than the paired release", () => { + expect(pairsWithCore(previousRelease())).toBe(false); + expect(pairsWithCore(`${MAJOR}.0.0`) && MAJOR === 0).toBe(false); + }); + + it("refuses an install it cannot read", () => { + expect(pairsWithCore(undefined)).toBe(false); + expect(pairsWithCore("not-a-version")).toBe(false); + expect(pairsWithCore("")).toBe(false); + }); +}); diff --git a/packages/create-threenative/__tests__/doctor-fix.spec.ts b/packages/create-threenative/__tests__/doctor-fix.spec.ts index 24e10c9669..d122bbb74f 100644 --- a/packages/create-threenative/__tests__/doctor-fix.spec.ts +++ b/packages/create-threenative/__tests__/doctor-fix.spec.ts @@ -196,6 +196,18 @@ describe("doctor --fix", () => { expect(await readFile(path.join(target, STAMP), "utf8")).toBe(stamp); }); + it("accepts a core newer than the paired release, the normal case for a fresh scaffold", async () => { + const target = await scaffold(); + const [major, minor] = CORE.split(".").map(Number) as [number, number]; + // Templates ship `>=`, so a game may resolve any newer engine — including a later minor. + await installCore(target, `${major}.${minor + 1}.0`); + await expect(fixAuthoringLayer(target, { force: true })).resolves.toEqual({ + deleted: [], + kept: [], + written: [], + }); + }); + it("refuses a game with no installed core at all, and names the upgrade", async () => { const target = await olderGame(); await rm(path.join(target, "node_modules"), { force: true, recursive: true }); diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index f935cfa20c..22c1efad43 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -117,67 +117,67 @@ const SCAFFOLD_TREE_HASHES: Readonly> = { // scaffold-pin:begin "action-rpg": { shared: "89aa333d822a6627d2ad23f96521abf4ea7ba92bcec1ed55f9c30a8ee83ff250", - kit: "a8d70b273cef24257fcffa5ff29033005501615a23a20ad6e542afee61013efa", + kit: "6cc2f1560f2f68724630383b3db8dadfe96636b84143ff1e26f2c64a51272b6a", }, adventure: { shared: "ddac540bdf78253f87db7432d1b80a2a4dc7e20809cd1006b3cc261492b7d6fa", - kit: "d2fa700c0186ed7eb9199700ed5b027078420801d0d09de76954364f94937d7a", + kit: "b7f39b0565b23e13e1d5452cf1ac450d31c5ef0a1e628e62d5b79d5a0a13b03d", }, collectathon: { shared: "3a0df8488b8d94deb01464e10fabde2d6ac4e65647e98f39f5afb235ec71ffae", - kit: "d442044c496f13384c098fa60573220cf41f5c0c07ac13ada43c1a5f0f64de72", + kit: "6c1a27670ecb528be120fef09851e7e0d05883e0453bf993161dfbd4b5d0a038", }, kart: { shared: "676434e30b0ed4e2dfe698e5d6ab393e47fe7779ef7df617ca8f12f1f24c7748", - kit: "edd8ff2a1a9bb9465eaa6c82ff830b57d75f3b594018c45cc7cf3a6c737bd9c2", + kit: "88430371871175bf3ab95086f4450089e8df119ef823911dfa049f5437b4add7", }, minimal: { shared: "f4b43e245b3a9810947d7a3b5652b409ffb097cafb7834ed215567f83d3379af", - kit: "29c32badb8a5c214d2284d6a2635570c03f3241feb8180cce5da681cd8222b96", + kit: "7b8213f03fcb0282bb1de3de5cfa44bfca55efce6a53f6a9e5800ff10c07f495", }, platformer: { shared: "0720f0c8f3374f5d41e7540dd3fe16f4ceeb2b5717fe2b163bafd4d7742983a8", - kit: "acb3a17cf7c1371188eedeab5e7a921929c56e35461f9c18c92edd031a56393f", + kit: "3e102d862444eac65584dca4dd550268652a01f349e7eefea994a9a09fc65bb1", }, puzzle: { shared: "865623ec3097b295dfc67012b3e61eaba0d7a322e2b6ec59620478863fdbdae6", - kit: "81db308b4a7c9dbb711555f4b4355d4c0fa3baa6d5d2f3a80327333fe5b39401", + kit: "e237631bdc0e72156381d699cb3e0d1b89a0ecfe3f00d65b101e1d58b6e602a4", }, racing: { shared: "8463fb44077a88e0c5938db571af259c59f668eedca9d477a0bc4b70239c3c60", - kit: "502ce8d65e38e69bfcec7c4c9981284048809676c6267f2a945534ed6a3f14c9", + kit: "75fe644366e883be5b9f98f2901208d6b1fbf84b9522a61b00de94b0ca91696e", }, rain: { shared: "f3d68ab1eb2759202e15499046cc80d362d3586be314f68610eed0bf2c20367e", - kit: "f0adf851fb6d0e14acdf903b47a00e3fa41ff88ca5f7086e43ea3a09d4609b96", + kit: "f36b4ae9fc412a295749096fcd03c2e37b14aeda3dcdf144a2e536001b1380e1", }, rts: { shared: "9e7c20d1b7eceb245eb9b1db1e30d1294f6533a9b69ef0f3e3bfad1c67d86b38", - kit: "15ae383f30b33a6ca2c3185f51d3365f5fbe71b49f1432a3d6b30c3dc576d669", + kit: "bf48b027dcc68e5be5e5fd18cb67f1b28ddc558b6fb2aaae7d927feab10c1cde", }, runner: { shared: "cd43eda17fbdc91bedb7680441daf0a30dee0f3b1e19bacebb124ab4471ff1ba", - kit: "b90f44f06df584db1557264c0fb07167adeb0b136c547839884ff1bcddca1158", + kit: "3e7b332420e36bad39c659b1e9bf933f0a489891a9bffa9f6dc41ea623822b5b", }, sailing: { shared: "6e39c4c67676a2c1d4ae7fdf148a0e7cc4dde3b6207b49da84701a4266e720c0", - kit: "db196dbad5d5b1e1c6b20bac8369c28426f7e2d7dac5e2ee948ec18a79e70cf0", + kit: "91a786b88aef46b7a943cf16dcf7b3f26bc6a6fefddb2e2827c76fadf97dd8b8", }, shooter: { shared: "dbe1eb0f2872275fc9759deea95c4c624f7fa82fb7adccab3d22a20889cabaac", - kit: "0e89ad8f17773794527b8bb153f265b6a7344792498f2a173447a666ecd60cf5", + kit: "572809c688d58403bdda547c90e8bcd01d9ab39ee5dd452c36647e7d149855cd", }, snow: { shared: "130389a38fb48663bd0cd391751ab3d2eb0c47e0d535e236b09366fd1fc93099", - kit: "4da758c138d7f80b9a4e88903e2c86172c0029bd160da1ebe5f37b72bcfea823", + kit: "aad24bfe38ba18bdb701216ac9734a5330b8fc3c142ff091a3137e2d309630c7", }, starter: { shared: "000bd75177222e5b3ab03beee4118b7c63a8dd59fdc316487d61ff7b3758376d", - kit: "9b70f462c27add3f689ea04925631afe92ab296b36309660107b900bf7f40a69", + kit: "d81a3f22192640f60b7780330e8b121e2b07ca309eeca1ac13fd7eba7d13a6b6", }, "tower-defense": { shared: "119e88ddb3fdcf2f3f4c0aade6e62e4b480e2b6a1292d69552040fb6e8083470", - kit: "7fa359a8a390a9bcbb2c832ba4fe66f45129170f387333173625cab93e0c7ba0", + kit: "ebf8cfa6e2a0ed443eebdc81573528eda9bbea5cb5a878b99bdcccf9dce7c90e", }, // scaffold-pin:end }; diff --git a/packages/create-threenative/src/core-pair.ts b/packages/create-threenative/src/core-pair.ts new file mode 100644 index 0000000000..274ddc54e3 --- /dev/null +++ b/packages/create-threenative/src/core-pair.ts @@ -0,0 +1,38 @@ +import { CORE_PACKAGE_VERSION } from "./mcp-servers.js"; + +/** `major.minor.patch`, with any prerelease or build suffix, as numbers; `undefined` otherwise. */ +function parts(value: string): readonly [number, number, number] | undefined { + const match = /^(\d+)\.(\d+)\.(\d+)/u.exec(value.trim()); + if (match === null) return undefined; + const parsed = [Number(match[1]), Number(match[2]), Number(match[3])] as const; + return parsed.every(Number.isSafeInteger) ? parsed : undefined; +} + +/** + * `0.3.13` -> `>=0.3.13`, matching what the templates ship. The scaffolder writes the specifier and + * the gates compare against it, so both spellings live in one place. + */ +export const CORE_TEMPLATE_FLOOR = `>=${CORE_PACKAGE_VERSION}`; + +/** + * Whether a game's installed core can carry the authoring layer this create-threenative writes. + * + * Any release at or after the one this package was built against, and never an older one. The + * templates ship `>=`, so a game scaffolded today installs whatever the registry calls newest — + * regularly newer than the create-threenative that wrote it — and exact equality turned every one + * of those into a refusal on a correct install. The case this check exists for is the opposite + * one: a game whose install is behind the CLI, where the layer would name MCP shims its core does + * not have (PRD-829). `assertMcpShims` and the engine's own `tn_version_handshake` still fail + * closed on a pair that cannot work; this is the readable error in front of them. + */ +export function pairsWithCore(installed: string | undefined): boolean { + if (installed === undefined) return false; + const have = parts(installed); + const paired = parts(CORE_PACKAGE_VERSION); + if (have === undefined || paired === undefined) return false; + const [haveMajor, haveMinor, havePatch] = have; + const [pairedMajor, pairedMinor, pairedPatch] = paired; + if (haveMajor !== pairedMajor) return haveMajor > pairedMajor; + if (haveMinor !== pairedMinor) return haveMinor > pairedMinor; + return havePatch >= pairedPatch; +} diff --git a/packages/create-threenative/src/index.ts b/packages/create-threenative/src/index.ts index 3ee37c8895..36953df27b 100644 --- a/packages/create-threenative/src/index.ts +++ b/packages/create-threenative/src/index.ts @@ -17,6 +17,7 @@ import { import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { pairsWithCore } from "./core-pair.js"; import { inspectCommand, inspectHelp } from "./inspect.js"; import { CORE_PACKAGE_NAME, @@ -1108,17 +1109,18 @@ async function installedCoreVersion(game: string): Promise { } /** - * Fails closed before `--fix` writes anything when the installed core is not the release this - * create-threenative is built against (PRD-829). The layer it writes names core's MCP shims, and - * the page boots the engine `@threenative/runtime-native` ships, so a mixed pair hands the game an - * authoring layer whose servers do not start and a page that throws `TN_ABI_6` at boot. + * Fails closed before `--fix` writes anything when the installed core is older than the release this + * create-threenative is built against (PRD-829). The layer it writes names core's MCP shims, and the + * page boots the engine `@threenative/runtime-native` ships, so a stale pair hands the game an + * authoring layer whose servers do not start and a page that throws `TN_ABI_6` at boot. A *newer* + * core is the normal case: templates ship `>=`, so a fresh game resolves the newest engine. */ async function assertEnginePair(game: string): Promise { const installed = await installedCoreVersion(game); - if (installed === CORE_PACKAGE_VERSION) return; + if (pairsWithCore(installed)) return; const got = installed === undefined ? "no installed core" : `${CORE_PACKAGE_NAME}@${installed}`; throw new Error( - `TN_AUTHORING_LAYER_PAIR: this create-threenative pairs with ${CORE_PACKAGE_NAME}@${CORE_PACKAGE_VERSION}, and this game installs ${got}. Run '${ENGINE_PAIR_UPGRADE}' first, then 'pnpm exec threenative doctor --fix'.`, + `TN_AUTHORING_LAYER_PAIR: this create-threenative pairs with ${CORE_PACKAGE_NAME}@${CORE_PACKAGE_VERSION} or newer, and this game installs ${got}. Run '${ENGINE_PAIR_UPGRADE}' first, then 'pnpm exec threenative doctor --fix'.`, ); } diff --git a/packages/create-threenative/templates/action-rpg/package.json b/packages/create-threenative/templates/action-rpg/package.json index 805735cd07..bb11d87806 100644 --- a/packages/create-threenative/templates/action-rpg/package.json +++ b/packages/create-threenative/templates/action-rpg/package.json @@ -19,9 +19,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.13", - "@threenative/physics": "0.3.13", - "@threenative/ui": "0.3.13", + "@threenative/core": ">=0.3.13", + "@threenative/physics": ">=0.3.13", + "@threenative/ui": ">=0.3.13", "react": "19.2.0", "react-dom": "19.2.0", "three": "0.187.0" @@ -29,8 +29,8 @@ "devDependencies": { "@gltf-transform/cli": "4.4.2", "@tailwindcss/vite": "4.3.3", - "@threenative/assets": "0.3.14", - "@threenative/playtest": "0.3.13", + "@threenative/assets": ">=0.3.14", + "@threenative/playtest": ">=0.3.13", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "@types/three": "0.187.0", @@ -45,7 +45,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.13" + "@threenative/runtime-native": ">=0.3.13" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/adventure/package.json b/packages/create-threenative/templates/adventure/package.json index da35bef86e..964b26f0bd 100644 --- a/packages/create-threenative/templates/adventure/package.json +++ b/packages/create-threenative/templates/adventure/package.json @@ -19,9 +19,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.13", - "@threenative/physics": "0.3.13", - "@threenative/ui": "0.3.13", + "@threenative/core": ">=0.3.13", + "@threenative/physics": ">=0.3.13", + "@threenative/ui": ">=0.3.13", "react": "19.2.0", "react-dom": "19.2.0", "three": "0.187.0" @@ -30,8 +30,8 @@ "@tailwindcss/vite": "4.3.3", "@vitejs/plugin-react": "6.0.5", "@types/three": "0.187.0", - "@threenative/assets": "0.3.14", - "@threenative/playtest": "0.3.13", + "@threenative/assets": ">=0.3.14", + "@threenative/playtest": ">=0.3.13", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "create-threenative": "0.2.18", @@ -45,7 +45,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.13" + "@threenative/runtime-native": ">=0.3.13" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/collectathon/package.json b/packages/create-threenative/templates/collectathon/package.json index da35bef86e..964b26f0bd 100644 --- a/packages/create-threenative/templates/collectathon/package.json +++ b/packages/create-threenative/templates/collectathon/package.json @@ -19,9 +19,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.13", - "@threenative/physics": "0.3.13", - "@threenative/ui": "0.3.13", + "@threenative/core": ">=0.3.13", + "@threenative/physics": ">=0.3.13", + "@threenative/ui": ">=0.3.13", "react": "19.2.0", "react-dom": "19.2.0", "three": "0.187.0" @@ -30,8 +30,8 @@ "@tailwindcss/vite": "4.3.3", "@vitejs/plugin-react": "6.0.5", "@types/three": "0.187.0", - "@threenative/assets": "0.3.14", - "@threenative/playtest": "0.3.13", + "@threenative/assets": ">=0.3.14", + "@threenative/playtest": ">=0.3.13", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "create-threenative": "0.2.18", @@ -45,7 +45,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.13" + "@threenative/runtime-native": ">=0.3.13" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/kart/package.json b/packages/create-threenative/templates/kart/package.json index d5f99a9a9d..9a7312bef6 100644 --- a/packages/create-threenative/templates/kart/package.json +++ b/packages/create-threenative/templates/kart/package.json @@ -19,8 +19,8 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.13", - "@threenative/ui": "0.3.13", + "@threenative/core": ">=0.3.13", + "@threenative/ui": ">=0.3.13", "react": "19.2.0", "react-dom": "19.2.0", "three": "0.187.0" @@ -29,8 +29,8 @@ "@tailwindcss/vite": "4.3.3", "@vitejs/plugin-react": "6.0.5", "@types/three": "0.187.0", - "@threenative/assets": "0.3.14", - "@threenative/playtest": "0.3.13", + "@threenative/assets": ">=0.3.14", + "@threenative/playtest": ">=0.3.13", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "create-threenative": "0.2.18", @@ -44,7 +44,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.13" + "@threenative/runtime-native": ">=0.3.13" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/minimal/package.json b/packages/create-threenative/templates/minimal/package.json index 2bf32228fb..05b3e960db 100644 --- a/packages/create-threenative/templates/minimal/package.json +++ b/packages/create-threenative/templates/minimal/package.json @@ -19,13 +19,13 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.13", - "@threenative/physics": "0.3.13", + "@threenative/core": ">=0.3.13", + "@threenative/physics": ">=0.3.13", "three": "0.187.0" }, "devDependencies": { - "@threenative/assets": "0.3.14", - "@threenative/playtest": "0.3.13", + "@threenative/assets": ">=0.3.14", + "@threenative/playtest": ">=0.3.13", "@types/three": "0.187.0", "create-threenative": "0.2.18", "esbuild": "0.27.0", @@ -37,7 +37,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.13" + "@threenative/runtime-native": ">=0.3.13" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/platformer/package.json b/packages/create-threenative/templates/platformer/package.json index da35bef86e..964b26f0bd 100644 --- a/packages/create-threenative/templates/platformer/package.json +++ b/packages/create-threenative/templates/platformer/package.json @@ -19,9 +19,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.13", - "@threenative/physics": "0.3.13", - "@threenative/ui": "0.3.13", + "@threenative/core": ">=0.3.13", + "@threenative/physics": ">=0.3.13", + "@threenative/ui": ">=0.3.13", "react": "19.2.0", "react-dom": "19.2.0", "three": "0.187.0" @@ -30,8 +30,8 @@ "@tailwindcss/vite": "4.3.3", "@vitejs/plugin-react": "6.0.5", "@types/three": "0.187.0", - "@threenative/assets": "0.3.14", - "@threenative/playtest": "0.3.13", + "@threenative/assets": ">=0.3.14", + "@threenative/playtest": ">=0.3.13", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "create-threenative": "0.2.18", @@ -45,7 +45,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.13" + "@threenative/runtime-native": ">=0.3.13" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/puzzle/package.json b/packages/create-threenative/templates/puzzle/package.json index 0ad5eda169..7c5d95e6d0 100644 --- a/packages/create-threenative/templates/puzzle/package.json +++ b/packages/create-threenative/templates/puzzle/package.json @@ -18,9 +18,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.13", - "@threenative/physics": "0.3.13", - "@threenative/ui": "0.3.13", + "@threenative/core": ">=0.3.13", + "@threenative/physics": ">=0.3.13", + "@threenative/ui": ">=0.3.13", "react": "19.2.0", "react-dom": "19.2.0", "three": "0.187.0" @@ -28,8 +28,8 @@ "devDependencies": { "@gltf-transform/cli": "4.4.2", "@tailwindcss/vite": "4.3.3", - "@threenative/assets": "0.3.14", - "@threenative/playtest": "0.3.13", + "@threenative/assets": ">=0.3.14", + "@threenative/playtest": ">=0.3.13", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "@types/three": "0.187.0", @@ -44,7 +44,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.13" + "@threenative/runtime-native": ">=0.3.13" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/racing/package.json b/packages/create-threenative/templates/racing/package.json index eb67eb3d31..f33a603e14 100644 --- a/packages/create-threenative/templates/racing/package.json +++ b/packages/create-threenative/templates/racing/package.json @@ -19,9 +19,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.13", - "@threenative/physics": "0.3.13", - "@threenative/ui": "0.3.13", + "@threenative/core": ">=0.3.13", + "@threenative/physics": ">=0.3.13", + "@threenative/ui": ">=0.3.13", "react": "19.2.0", "react-dom": "19.2.0", "three": "0.187.0" @@ -29,8 +29,8 @@ "devDependencies": { "@gltf-transform/cli": "4.4.2", "@tailwindcss/vite": "4.3.3", - "@threenative/assets": "0.3.14", - "@threenative/playtest": "0.3.13", + "@threenative/assets": ">=0.3.14", + "@threenative/playtest": ">=0.3.13", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "@types/three": "0.187.0", @@ -45,7 +45,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.13" + "@threenative/runtime-native": ">=0.3.13" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/rain/package.json b/packages/create-threenative/templates/rain/package.json index 571daa7c20..29dae69c6e 100644 --- a/packages/create-threenative/templates/rain/package.json +++ b/packages/create-threenative/templates/rain/package.json @@ -18,8 +18,8 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.13", - "@threenative/ui": "0.3.13", + "@threenative/core": ">=0.3.13", + "@threenative/ui": ">=0.3.13", "react": "19.2.0", "react-dom": "19.2.0", "react-reconciler": "0.33.0", @@ -27,8 +27,8 @@ }, "devDependencies": { "@tailwindcss/vite": "4.3.3", - "@threenative/assets": "0.3.14", - "@threenative/playtest": "0.3.13", + "@threenative/assets": ">=0.3.14", + "@threenative/playtest": ">=0.3.13", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "@types/three": "0.187.0", @@ -45,7 +45,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.13" + "@threenative/runtime-native": ">=0.3.13" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/rts/package.json b/packages/create-threenative/templates/rts/package.json index d236c27e1d..e46ade3c05 100644 --- a/packages/create-threenative/templates/rts/package.json +++ b/packages/create-threenative/templates/rts/package.json @@ -19,8 +19,8 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.13", - "@threenative/ui": "0.3.13", + "@threenative/core": ">=0.3.13", + "@threenative/ui": ">=0.3.13", "react": "19.2.0", "react-dom": "19.2.0", "three": "0.187.0" @@ -28,8 +28,8 @@ "devDependencies": { "@gltf-transform/cli": "4.4.2", "@tailwindcss/vite": "4.3.3", - "@threenative/assets": "0.3.14", - "@threenative/playtest": "0.3.13", + "@threenative/assets": ">=0.3.14", + "@threenative/playtest": ">=0.3.13", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "@types/three": "0.187.0", @@ -44,7 +44,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.13" + "@threenative/runtime-native": ">=0.3.13" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/runner/package.json b/packages/create-threenative/templates/runner/package.json index 0ad5eda169..7c5d95e6d0 100644 --- a/packages/create-threenative/templates/runner/package.json +++ b/packages/create-threenative/templates/runner/package.json @@ -18,9 +18,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.13", - "@threenative/physics": "0.3.13", - "@threenative/ui": "0.3.13", + "@threenative/core": ">=0.3.13", + "@threenative/physics": ">=0.3.13", + "@threenative/ui": ">=0.3.13", "react": "19.2.0", "react-dom": "19.2.0", "three": "0.187.0" @@ -28,8 +28,8 @@ "devDependencies": { "@gltf-transform/cli": "4.4.2", "@tailwindcss/vite": "4.3.3", - "@threenative/assets": "0.3.14", - "@threenative/playtest": "0.3.13", + "@threenative/assets": ">=0.3.14", + "@threenative/playtest": ">=0.3.13", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "@types/three": "0.187.0", @@ -44,7 +44,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.13" + "@threenative/runtime-native": ">=0.3.13" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/sailing/package.json b/packages/create-threenative/templates/sailing/package.json index 8f9eec1a57..12411c9f7b 100644 --- a/packages/create-threenative/templates/sailing/package.json +++ b/packages/create-threenative/templates/sailing/package.json @@ -19,9 +19,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.13", - "@threenative/physics": "0.3.13", - "@threenative/ui": "0.3.13", + "@threenative/core": ">=0.3.13", + "@threenative/physics": ">=0.3.13", + "@threenative/ui": ">=0.3.13", "react": "19.2.0", "react-dom": "19.2.0", "react-reconciler": "0.33.0", @@ -29,8 +29,8 @@ }, "devDependencies": { "@tailwindcss/vite": "4.3.3", - "@threenative/assets": "0.3.14", - "@threenative/playtest": "0.3.13", + "@threenative/assets": ">=0.3.14", + "@threenative/playtest": ">=0.3.13", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "@types/three": "0.187.0", @@ -46,7 +46,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.13" + "@threenative/runtime-native": ">=0.3.13" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/shooter/package.json b/packages/create-threenative/templates/shooter/package.json index 4d30214f44..77ee91fa1c 100644 --- a/packages/create-threenative/templates/shooter/package.json +++ b/packages/create-threenative/templates/shooter/package.json @@ -19,9 +19,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.13", - "@threenative/physics": "0.3.13", - "@threenative/ui": "0.3.13", + "@threenative/core": ">=0.3.13", + "@threenative/physics": ">=0.3.13", + "@threenative/ui": ">=0.3.13", "react": "19.2.0", "react-dom": "19.2.0", "three": "0.187.0" @@ -30,8 +30,8 @@ "@tailwindcss/vite": "4.3.3", "@types/three": "0.187.0", "@vitejs/plugin-react": "6.0.5", - "@threenative/assets": "0.3.14", - "@threenative/playtest": "0.3.13", + "@threenative/assets": ">=0.3.14", + "@threenative/playtest": ">=0.3.13", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "playwright": "1.62.1", @@ -45,7 +45,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.13" + "@threenative/runtime-native": ">=0.3.13" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/snow/package.json b/packages/create-threenative/templates/snow/package.json index 54350f30e9..fbfa67fd75 100644 --- a/packages/create-threenative/templates/snow/package.json +++ b/packages/create-threenative/templates/snow/package.json @@ -19,9 +19,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.13", - "@threenative/physics": "0.3.13", - "@threenative/ui": "0.3.13", + "@threenative/core": ">=0.3.13", + "@threenative/physics": ">=0.3.13", + "@threenative/ui": ">=0.3.13", "react": "19.2.0", "react-dom": "19.2.0", "react-reconciler": "0.33.0", @@ -29,8 +29,8 @@ }, "devDependencies": { "@tailwindcss/vite": "4.3.3", - "@threenative/assets": "0.3.14", - "@threenative/playtest": "0.3.13", + "@threenative/assets": ">=0.3.14", + "@threenative/playtest": ">=0.3.13", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "@types/three": "0.187.0", @@ -46,7 +46,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.13" + "@threenative/runtime-native": ">=0.3.13" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/starter/package.json b/packages/create-threenative/templates/starter/package.json index f14a193a79..14f4f8254c 100644 --- a/packages/create-threenative/templates/starter/package.json +++ b/packages/create-threenative/templates/starter/package.json @@ -20,9 +20,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.13", - "@threenative/physics": "0.3.13", - "@threenative/ui": "0.3.13", + "@threenative/core": ">=0.3.13", + "@threenative/physics": ">=0.3.13", + "@threenative/ui": ">=0.3.13", "react": "19.2.0", "react-dom": "19.2.0", "react-reconciler": "0.33.0", @@ -30,8 +30,8 @@ }, "devDependencies": { "@tailwindcss/vite": "4.3.3", - "@threenative/assets": "0.3.14", - "@threenative/playtest": "0.3.13", + "@threenative/assets": ">=0.3.14", + "@threenative/playtest": ">=0.3.13", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "@types/three": "0.187.0", @@ -47,7 +47,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.13" + "@threenative/runtime-native": ">=0.3.13" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/tower-defense/package.json b/packages/create-threenative/templates/tower-defense/package.json index 0ad5eda169..7c5d95e6d0 100644 --- a/packages/create-threenative/templates/tower-defense/package.json +++ b/packages/create-threenative/templates/tower-defense/package.json @@ -18,9 +18,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.13", - "@threenative/physics": "0.3.13", - "@threenative/ui": "0.3.13", + "@threenative/core": ">=0.3.13", + "@threenative/physics": ">=0.3.13", + "@threenative/ui": ">=0.3.13", "react": "19.2.0", "react-dom": "19.2.0", "three": "0.187.0" @@ -28,8 +28,8 @@ "devDependencies": { "@gltf-transform/cli": "4.4.2", "@tailwindcss/vite": "4.3.3", - "@threenative/assets": "0.3.14", - "@threenative/playtest": "0.3.13", + "@threenative/assets": ">=0.3.14", + "@threenative/playtest": ">=0.3.13", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "@types/three": "0.187.0", @@ -44,7 +44,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.13" + "@threenative/runtime-native": ">=0.3.13" }, "overrides": { "sharp": ">=0.35.4", diff --git a/scripts/__tests__/check-publish-state.spec.ts b/scripts/__tests__/check-publish-state.spec.ts index efe023d349..dbd9e1ae98 100644 --- a/scripts/__tests__/check-publish-state.spec.ts +++ b/scripts/__tests__/check-publish-state.spec.ts @@ -438,6 +438,23 @@ describe("pnpm publish:check", () => { expect(findings[0]?.detail).toMatch(/@threenative\/core@9\.9\.9/u); }); + it("resolves the floor of a template range, not a name the registry has never served", async () => { + const root = await fixture(); + write( + root, + "packages/create-threenative/templates/starter/package.json", + JSON.stringify({ dependencies: { "@threenative/core": ">=9.9.9", three: "0.185.1" } }), + ); + const findings = templatePinCensus(root, (name, version) => + name === "@threenative/core" && version === "9.9.9" + ? { state: "absent" } + : { state: "present", version: "0.0.0" }, + ); + expect(findings).toHaveLength(1); + expect(findings[0]?.package).toBe("template:starter"); + expect(findings[0]?.detail).toMatch(/@threenative\/core@9\.9\.9/u); + }); + it("rejects an absent exact internal pin at the version in the current publish set by default", async () => { const root = await fixture(); const lookup: RegistryLookup = (name, version) => diff --git a/scripts/__tests__/check-version-pins.spec.ts b/scripts/__tests__/check-version-pins.spec.ts index 22a182ee1b..f2c9aac0dc 100644 --- a/scripts/__tests__/check-version-pins.spec.ts +++ b/scripts/__tests__/check-version-pins.spec.ts @@ -49,9 +49,9 @@ async function pinFixture(): Promise { for (const template of ["alpha", "beta"]) { await writeJson(root, `packages/create-threenative/templates/${template}/package.json`, { dependencies: { - "@threenative/core": "1.0.0", - "@threenative/physics": "1.0.0", - "@threenative/runtime-native": "1.0.0", + "@threenative/core": ">=1.0.0", + "@threenative/physics": ">=1.0.0", + "@threenative/runtime-native": ">=1.0.0", react: "19.2.0", }, devDependencies: { @@ -81,8 +81,8 @@ describe("template version pin gate", () => { await writeJson(root, "packages/create-threenative/templates/alpha/package.json", { dependencies: { "@threenative/core": "9.9.9", - "@threenative/physics": "1.0.0", - "@threenative/runtime-native": "1.0.0", + "@threenative/physics": ">=1.0.0", + "@threenative/runtime-native": ">=1.0.0", react: "19.2.0", }, devDependencies: { "create-threenative": "2.0.0", "threenative-engine-mcp": "3.0.0" }, @@ -98,10 +98,10 @@ describe("template version pin gate", () => { const root = await pinFixture(); await writeJson(root, "packages/create-threenative/templates/alpha/package.json", { dependencies: { - "@threenative/core": "1.0.0", + "@threenative/core": ">=1.0.0", "@threenative/croe": "1.0.0", - "@threenative/physics": "1.0.0", - "@threenative/runtime-native": "1.0.0", + "@threenative/physics": ">=1.0.0", + "@threenative/runtime-native": ">=1.0.0", react: "19.2.0", }, devDependencies: { "create-threenative": "2.0.0", "threenative-engine-mcp": "3.0.0" }, @@ -117,9 +117,9 @@ describe("template version pin gate", () => { const root = await pinFixture(); await writeJson(root, "packages/create-threenative/templates/beta/package.json", { dependencies: { - "@threenative/core": "1.0.0", - "@threenative/physics": "1.0.0", - "@threenative/runtime-native": "1.0.0", + "@threenative/core": ">=1.0.0", + "@threenative/physics": ">=1.0.0", + "@threenative/runtime-native": ">=1.0.0", react: "20.0.0", }, devDependencies: { "create-threenative": "2.0.0", "threenative-engine-mcp": "3.0.0" }, @@ -129,4 +129,53 @@ describe("template version pin gate", () => { "third-party dependency react is 19.2.0 in template alpha but 20.0.0 in template beta", ]); }); + + it("accepts a floor below the workspace version, because a game resolves the newest engine", async () => { + const root = await pinFixture(); + await writeJson(root, "packages/create-threenative/templates/alpha/package.json", { + dependencies: { + "@threenative/core": ">=0.4.0", + "@threenative/physics": ">=1.0.0", + "@threenative/runtime-native": ">=1.0.0", + react: "19.2.0", + }, + devDependencies: { "create-threenative": "2.0.0", "threenative-engine-mcp": "3.0.0" }, + }); + + await expect(checkVersionPins(root)).resolves.toEqual([]); + }); + + it("rejects a floor above the workspace version, which no install could satisfy", async () => { + const root = await pinFixture(); + await writeJson(root, "packages/create-threenative/templates/alpha/package.json", { + dependencies: { + "@threenative/core": ">=2.0.0", + "@threenative/physics": ">=1.0.0", + "@threenative/runtime-native": ">=1.0.0", + react: "19.2.0", + }, + devDependencies: { "create-threenative": "2.0.0", "threenative-engine-mcp": "3.0.0" }, + }); + + const findings = await checkVersionPins(root); + expect(findings.join("\n")).toContain( + "template alpha pins @threenative/core at >=2.0.0; expected workspace version 1.0.0 as >=1.0.0", + ); + }); + + it("rejects an exact internal pin that is not the workspace version", async () => { + const root = await pinFixture(); + await writeJson(root, "packages/create-threenative/templates/alpha/package.json", { + dependencies: { + "@threenative/core": "0.9.0", + "@threenative/physics": ">=1.0.0", + "@threenative/runtime-native": ">=1.0.0", + react: "19.2.0", + }, + devDependencies: { "create-threenative": "2.0.0", "threenative-engine-mcp": "3.0.0" }, + }); + + const findings = await checkVersionPins(root); + expect(findings.join("\n")).toContain("template alpha pins @threenative/core at 0.9.0"); + }); }); diff --git a/scripts/__tests__/release.spec.ts b/scripts/__tests__/release.spec.ts index aa6a4b466d..8b043a4606 100644 --- a/scripts/__tests__/release.spec.ts +++ b/scripts/__tests__/release.spec.ts @@ -361,6 +361,30 @@ describe("pnpm release cohort guards", () => { expect(findings[0]).toMatch(/@threenative\/core.*0\.3\.0/u); }); + it("reads a template floor, so a later candidate still satisfies the kit's pin", async () => { + const root = await makeTempDir("threenative-release-floors-"); + roots.push(root); + const templates = path.join(root, "templates", "starter"); + await fs.promises.mkdir(templates, { recursive: true }); + const manifest = (specifier: string): Promise => + fs.promises.writeFile( + path.join(templates, "package.json"), + JSON.stringify({ dependencies: { "@threenative/core": specifier } }), + ); + await manifest(">=0.3.0"); + expect( + validateTemplatePins(path.join(root, "templates"), new Map([["@threenative/core", "0.3.4"]])), + ).toEqual([]); + // A floor above the candidate names an engine the registry cannot serve yet. + await manifest(">=0.4.0"); + const findings = validateTemplatePins( + path.join(root, "templates"), + new Map([["@threenative/core", "0.3.4"]]), + ); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatch(/@threenative\/core.*0\.3\.4/u); + }); + it("refuses preparation when a published package changed without a version bump", async () => { const repo = path.resolve(import.meta.dirname, "../.."); const packages = publishSet(repo); diff --git a/scripts/__tests__/version-range.spec.ts b/scripts/__tests__/version-range.spec.ts new file mode 100644 index 0000000000..6e1931d1dc --- /dev/null +++ b/scripts/__tests__/version-range.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + rangeFloor, + rangeForVersion, + satisfiesSpecifier, + templateSpecifier, +} from "../version-range.js"; + +describe("template version floors", () => { + it("turns a workspace version into an open floor", () => { + expect(rangeForVersion("0.3.13")).toBe(">=0.3.13"); + expect(rangeForVersion("1.4.2")).toBe(">=1.4.2"); + }); + + it("floats our packages and pins the tool", () => { + expect(templateSpecifier("@threenative/core", "0.3.13")).toBe(">=0.3.13"); + expect(templateSpecifier("@threenative/runtime-native", "0.3.13")).toBe(">=0.3.13"); + expect(templateSpecifier("create-threenative", "0.2.18")).toBe("0.2.18"); + expect(templateSpecifier("threenative-engine-mcp", "0.4.0")).toBe("0.4.0"); + }); + + it("reads the floor back out", () => { + expect(rangeFloor(">=0.3.13")).toBe("0.3.13"); + expect(rangeFloor("0.3.13")).toBeUndefined(); + expect(rangeFloor("^0.3.13")).toBeUndefined(); + }); + + it("admits every newer release, including a later major, and refuses an older one", () => { + expect(satisfiesSpecifier("0.3.13", ">=0.3.13")).toBe(true); + expect(satisfiesSpecifier("0.3.99", ">=0.3.13")).toBe(true); + expect(satisfiesSpecifier("0.4.0", ">=0.3.13")).toBe(true); + expect(satisfiesSpecifier("1.0.0", ">=0.3.13")).toBe(true); + expect(satisfiesSpecifier("0.3.12", ">=0.3.13")).toBe(false); + expect(satisfiesSpecifier("0.2.99", ">=0.3.13")).toBe(false); + }); + + it("still compares an exact pin exactly", () => { + expect(satisfiesSpecifier("0.2.18", "0.2.18")).toBe(true); + expect(satisfiesSpecifier("0.2.19", "0.2.18")).toBe(false); + }); + + it("refuses a version it cannot read instead of passing it", () => { + expect(() => rangeForVersion("0.3")).toThrow(/TN_VERSION_RANGE/u); + expect(satisfiesSpecifier("0.3.13", ">=0.3")).toBe(false); + }); +}); diff --git a/scripts/check-publish-state.ts b/scripts/check-publish-state.ts index 60d19e4cb9..3135b7e027 100644 --- a/scripts/check-publish-state.ts +++ b/scripts/check-publish-state.ts @@ -31,6 +31,7 @@ const { platformKey, releaseManifestUrl } = (await import( readonly platformKey: (platform?: string, arch?: string) => string; readonly releaseManifestUrl: (version?: string) => string; }; +import { rangeFloor, satisfiesSpecifier } from "./version-range.js"; import { publicWorkspacePackages, workspacePackages } from "./workspace-packages.js"; const REPO = path.resolve(import.meta.dirname, ".."); @@ -327,11 +328,15 @@ export function templatePinCensus( if (typeof block !== "object" || block === null || Array.isArray(block)) continue; for (const [dependency, specifier] of Object.entries(block as Record)) { if (typeof specifier !== "string" || UNRESOLVED.test(specifier)) continue; - const pin = `${dependency}@${specifier}`; + // A template names a floor, not a version: its ceiling is "whatever is newest". The floor is + // the release the kit was tested against, so that is what the registry has to be able to + // serve before a game can resolve anything at all. + const requested = rangeFloor(specifier) ?? specifier; + const pin = `${dependency}@${requested}`; let facts = seen.get(pin); if (facts === undefined) { try { - facts = lookup(dependency, specifier); + facts = lookup(dependency, requested); } catch { facts = { state: "unreachable" }; } diff --git a/scripts/check-version-pins.ts b/scripts/check-version-pins.ts index 6fb798b79f..bdaf25e857 100644 --- a/scripts/check-version-pins.ts +++ b/scripts/check-version-pins.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from "node:fs"; import { readFile, readdir } from "node:fs/promises"; import path from "node:path"; +import { satisfiesSpecifier, templateSpecifier } from "./version-range.js"; import { workspacePackages } from "./workspace-packages.js"; type IManifest = { @@ -104,9 +105,10 @@ export async function checkVersionPins(root = process.cwd()): Promise): number { +/** + * Rewrite every template's workspace pin from the current versions. Exported because a template's + * floor is meaningless to a game — `>=` resolves the newest published engine — so this is the one + * writer that keeps the checked-in pins from drifting below what the kit was tested against. + */ +export function syncTemplatePins(versions: ReadonlyMap): number { let changed = 0; for (const entry of readdirSync(TEMPLATE_ROOT, { withFileTypes: true })) { if (!entry.isDirectory()) continue; @@ -153,7 +159,7 @@ function syncTemplatePins(versions: ReadonlyMap): number { let source = readFileSync(file, "utf8"); for (const [name, version] of versions) { const pattern = new RegExp(`("${escapeRegExp(name)}"\\s*:\\s*")[^"]+("\\s*,?)`, "gu"); - source = source.replace(pattern, `$1${version}$2`); + source = source.replace(pattern, `$1${templateSpecifier(name, version)}$2`); } if (writeIfChanged(file, source)) changed += 1; } diff --git a/scripts/release.ts b/scripts/release.ts index b159cb2c2f..52bf3b866e 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -47,6 +47,7 @@ import { type IVerifyRegistryInstallOptions, verifyRegistryInstall, } from "./verify-registry-install.js"; +import { satisfiesSpecifier } from "./version-range.js"; const REPO = path.resolve(import.meta.dirname, ".."); @@ -88,7 +89,8 @@ export function validateTemplatePins( if (typeof block !== "object" || block === null || Array.isArray(block)) continue; for (const [dependency, specifier] of Object.entries(block as Record)) { const candidate = versions.get(dependency); - if (candidate === undefined || specifier === candidate) continue; + if (candidate === undefined || typeof specifier !== "string") continue; + if (satisfiesSpecifier(candidate, specifier)) continue; findings.push( `templates/${entry.name}/package.json ${field}.${dependency} pins '${String(specifier)}', but the candidate cohort carries '${candidate}'.`, ); diff --git a/scripts/version-range.ts b/scripts/version-range.ts new file mode 100644 index 0000000000..6f1497749f --- /dev/null +++ b/scripts/version-range.ts @@ -0,0 +1,61 @@ +/** + * The version a template ships for one workspace package. + * + * A generated game resolves its engine at install time, so a kit pinned to whatever release was + * current when `create-threenative` was last published handed every new project an engine older + * than the registry's newest, sometimes by weeks. A template therefore names a floor and no + * ceiling: `>=0.3.13` installs the newest published engine, forever, without a template repack. + * + * The floor is the release the kit's source was last tested against, and it only ever moves in + * `pnpm release:prepare`. Nothing user-facing depends on it staying current, so a release that + * forgets to move it costs nothing — which is the point. + * + * A game already scaffolded is frozen by its own `pnpm-lock.yaml`, so this floats new projects and + * never silently upgrades an existing one. + * + * `create-threenative` itself stays exact. It is the tool that writes a game's authoring layer, so + * a floating tool would be choosing its own input. + */ + +type ITriple = readonly [number, number, number]; + +const TRIPLE = /^(\d+)\.(\d+)\.(\d+)/u; +const RANGE = /^>=\s*(\d+\.\d+\.\d+)$/u; + +function triple(value: string, what: string): ITriple { + const match = TRIPLE.exec(value.trim()); + if (match === null) + throw new Error(`TN_VERSION_RANGE: ${what} '${value}' is not a released version.`); + const parts = [Number(match[1]), Number(match[2]), Number(match[3])] as const; + if (!parts.every(Number.isSafeInteger)) + throw new Error(`TN_VERSION_RANGE: ${what} '${value}' is not a released version.`); + return parts; +} + +function compare(left: ITriple, right: ITriple): number { + const difference = left[0] - right[0] || left[1] - right[1] || left[2] - right[2]; + return Math.sign(difference); +} + +/** The specifier a template declares for one workspace package. */ +export function templateSpecifier(name: string, version: string): string { + return name.startsWith("@threenative/") ? rangeForVersion(version) : version; +} + +/** `0.3.13` -> `>=0.3.13`: the newest published engine, whatever major it becomes. */ +export function rangeForVersion(version: string): string { + triple(version, "version"); + return `>=${version.trim()}`; +} + +/** The published floor of a `>=A` specifier, or `undefined` for anything that is not one. */ +export function rangeFloor(specifier: string): string | undefined { + return RANGE.exec(specifier.trim())?.[1]; +} + +/** Whether a version satisfies an exact pin or a `>=A` floor. */ +export function satisfiesSpecifier(version: string, specifier: string): boolean { + const floor = rangeFloor(specifier); + if (floor === undefined) return version.trim() === specifier.trim(); + return compare(triple(version, "version"), triple(floor, "range floor")) >= 0; +}