Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 104 additions & 6 deletions packages/core/mcp/engine-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -704,8 +704,11 @@ function suffixStripped(token) {
return token;
}
var STOP_WORD_STEMS = new Set([...STOP_WORDS].map(stem));
function splitSymbolCase(value) {
return value.replace(/([a-z0-9])([A-Z])/gu, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/gu, "$1 $2");
}
function capabilitySituationTokens(value) {
return value.toLocaleLowerCase().split(/[^a-z0-9]+/u).filter((token) => token.length > 1 && !STOP_WORDS.has(token)).map(stem).filter((token) => !STOP_WORD_STEMS.has(token));
return splitSymbolCase(value).toLocaleLowerCase().split(/[^a-z0-9]+/u).filter((token) => token.length > 1 && !STOP_WORDS.has(token)).map(stem).filter((token) => !STOP_WORD_STEMS.has(token));
}
var tokens = capabilitySituationTokens;
function tokenWeights(entries) {
Expand All @@ -725,6 +728,36 @@ function tokenWeights(entries) {
function weightOf(weights, token) {
return weights.get(token) ?? 0;
}
function nameWords(value) {
return tokens(value.replace(/([a-zA-Z])(\d+)/gu, "$1 $2")).filter((token) => !/\d/u.test(token));
}
function normaliseName(value) {
return value.toLocaleLowerCase().replace(/[^a-z0-9]+/gu, "");
}
function spelledWords(value) {
return value.toLocaleLowerCase().split(/[^a-z0-9]+/u).filter((word) => word.length > 0);
}
function spellsName(situation, symbol) {
const words = spelledWords(situation);
const whole = normaliseName(symbol);
for (let start = 0; start < words.length; start += 1) {
let piece = "";
for (let end = start; end < words.length && end < start + 4; end += 1) {
piece += normaliseName(words[end] ?? "");
if (piece === whole) return true;
if (piece.length >= whole.length) break;
}
}
return false;
}
function nameTokenWeights(entries) {
return tokenWeights(
entries.map((entry) => ({
aliases: [nameWords(entry.symbol).join(" ")],
situations: [entry.summary]
}))
);
}
var DISTINCTIVE_SITUATION_SHARE = 0.02;
var DISTINCTIVE_FLOOR = Math.log(1 + 1 / DISTINCTIVE_SITUATION_SHARE);
var AGREEMENT_WEIGHT = 0.4;
Expand Down Expand Up @@ -785,6 +818,62 @@ function situationScore(query, situations, aliases, weights) {
const matchedSituation = readable.matchedSituation.length > 0 ? readable.matchedSituation : best >= RELEVANCE_FLOOR ? readable.bestReadableSituation : "";
return matchedSituation.length > 0 ? { matchedSituation, score: best } : { matchedSituation: "", score: 0 };
}
var EXACT_SYMBOL_LEAD = 4;
var NAME_BASE_LEAD = 2;
var NAME_SHARE_LEAD = 1.4;
var SYMBOL_WEIGHT_FLOOR = 4.2;
var SINGLE_WORD_SHARE_FLOOR = 0.3;
function scoreSymbolName(symbol, situation, queryTokens, weights) {
const name = [...new Set(nameWords(symbol))];
if (name.length === 0) return void 0;
const nameWeights = name.map((token) => weightOf(weights, token));
const specificity = nameWeights.reduce((sum, weight) => sum + weight, 0);
const requestTotal = [...queryTokens].reduce((sum, token) => sum + weightOf(weights, token), 0);
const share = requestTotal === 0 ? 0 : specificity / requestTotal;
const nameScore = NAME_BASE_LEAD + NAME_SHARE_LEAD * Math.min(1, share);
if (spellsName(situation, symbol)) {
if (normaliseName(situation) === normaliseName(symbol)) {
return { score: EXACT_SYMBOL_LEAD, specificity };
}
if (name.length === 1) {
if (weightOf(weights, name[0] ?? "") < SYMBOL_WEIGHT_FLOOR) return void 0;
if (share < SINGLE_WORD_SHARE_FLOOR) return void 0;
return { score: (NAME_BASE_LEAD + NAME_SHARE_LEAD) * Math.min(1, share), specificity };
}
return { score: nameScore, specificity };
}
if (name.length < 2) return void 0;
const queryWords = new Set(nameWords(situation));
if (!name.every((token) => queryWords.has(token))) return void 0;
if (Math.max(...nameWeights) < SYMBOL_WEIGHT_FLOOR) return void 0;
return { score: nameScore, specificity };
}
var SUMMARY_WEIGHT = 0.4;
function summaryScore(summary, queryTokens, queryText, weights) {
const candidate = scorePhrase(summary, queryTokens, queryText, weights);
return candidate === void 0 ? 0 : candidate.score * SUMMARY_WEIGHT;
}
function capabilityScore(query, entry, weights, nameWeights, situation) {
const situations = {
...situationScore(query, entry.situations, entry.aliases, weights),
specificity: 0
};
if (query.length === 0) return situations;
const queryTokens = new Set(query);
const symbol = scoreSymbolName(entry.symbol, situation, queryTokens, nameWeights);
const summary = summaryScore(entry.summary, queryTokens, query.join(" "), nameWeights);
const symbolScore = symbol?.score ?? 0;
const best = Math.max(situations.score, symbolScore, summary);
if (best <= situations.score) return situations;
if (symbol !== void 0 && symbolScore >= best) {
return { matchedSituation: entry.symbol, score: symbolScore, specificity: symbol.specificity };
}
return {
matchedSituation: situations.matchedSituation || entry.situations[0] || entry.summary,
score: best,
specificity: 0
};
}
function notOwnedSituationScore(query, situations) {
if (query.length === 0) return { matchedSituation: "", score: 0 };
const queryText = query.join(" ");
Expand Down Expand Up @@ -885,6 +974,7 @@ function searchMechanics(situation, manifest, scope) {
const query = tokens(situation);
const limit = scope === "request" ? MAX_COMPLETE_REQUEST_RESULTS : MAX_SITUATION_RESULTS;
const weights = tokenWeights(manifest.entries);
const nameWeights = nameTokenWeights(manifest.entries);
const notOwned = notOwnedMatch(manifest, query);
if (notOwned !== void 0 && isSpecificNotOwnedMatch(query, notOwned.matchedSituation)) {
return {
Expand All @@ -893,10 +983,10 @@ function searchMechanics(situation, manifest, scope) {
verdict: "none"
};
}
const results = manifest.entries.map((entry) => ({ entry, ...situationScore(query, entry.situations, entry.aliases, weights) })).filter(
const results = manifest.entries.map((entry) => ({ entry, ...capabilityScore(query, entry, weights, nameWeights, situation) })).filter(
({ matchedSituation, score }) => matchedSituation.length > 0 && score >= RELEVANCE_FLOOR
).sort(
(left, right) => right.score - left.score || `${left.entry.importPath}:${left.entry.symbol}`.localeCompare(
(left, right) => right.score - left.score || right.specificity - left.specificity || `${left.entry.importPath}:${left.entry.symbol}`.localeCompare(
`${right.entry.importPath}:${right.entry.symbol}`
)
).filter(
Expand All @@ -905,11 +995,14 @@ function searchMechanics(situation, manifest, scope) {
) === index
).slice(0, limit).map(({ entry, matchedSituation, score }) => ({
constraints: entry.constraints,
...entry.deprecated === void 0 ? {} : { deprecated: entry.deprecated },
example: entry.example,
importPath: entry.importPath,
matchedSituation,
...entry.overrides === void 0 ? {} : { overrides: entry.overrides },
...entry.requires === void 0 ? {} : { requires: entry.requires },
score,
signature: entry.signature,
summary: entry.summary,
symbol: entry.symbol
}));
Expand Down Expand Up @@ -944,7 +1037,7 @@ var TOOL_DEFINITIONS = [
{
annotations: { destructiveHint: false, openWorldHint: false, readOnlyHint: true },
name: "engine_search_capabilities",
description: 'Search the installed engine by concrete gameplay mechanic. Decompose genres first. Use scope "request" for the mechanically explicit full request and "mechanic" for each focused search; matchedSituation and score explain every result. The response verdict "none" is an actionable answer with guidance, not a failed search.',
description: 'Search the installed engine by concrete gameplay mechanic or by an exact exported symbol name. Decompose genres first. Use scope "request" for the mechanically explicit full request and "mechanic" for each focused search; matchedSituation and score explain every result. A row carries the answer (importPath, signature, example, constraints, overrides, deprecations), so call engine_capability_detail only for a symbol the search did not return. The response verdict "none" is an actionable answer with guidance, not a failed search.',
inputSchema: {
additionalProperties: false,
properties: {
Expand Down Expand Up @@ -1073,7 +1166,7 @@ function writeLine(value) {
`);
}
function toolText(value) {
return { content: [{ text: JSON.stringify(value, null, 2), type: "text" }] };
return { content: [{ text: JSON.stringify(value), type: "text" }] };
}
async function searchReferences(situation, limit = 5) {
const local = loadLocalReferenceGames();
Expand Down Expand Up @@ -1293,7 +1386,12 @@ async function runCli(argv, manifestFile = defaultManifestPath(), io = {
{ arguments: JSON.parse(json), name },
manifestFile
);
io.stdout(`${result.content[0].text}
let text2 = result.content[0].text;
try {
text2 = JSON.stringify(JSON.parse(text2), null, 2);
} catch {
}
io.stdout(`${text2}
`);
return 0;
} catch (error) {
Expand Down
32 changes: 16 additions & 16 deletions packages/create-threenative/__tests__/scaffold.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,67 +116,67 @@ const PACK_GENERATED_PAGES: readonly string[] = ["capability-reference.md"];
const SCAFFOLD_TREE_HASHES: Readonly<Record<string, IScaffoldTreePin>> = {
// scaffold-pin:begin
"action-rpg": {
shared: "618fc0da29be00df175c654b9e0239971fff904ca90d15316ac833cf4d3360b8",
shared: "e771f2032c1121b03e5213aea9d17e13a979d5466925c0acfc771b8afe577c60",
kit: "a8d70b273cef24257fcffa5ff29033005501615a23a20ad6e542afee61013efa",
},
adventure: {
shared: "3ffa5def15f688e3ea87eb689b35dffe18e12c98df7a52497d483c492313d0dd",
shared: "0c17a4c036d6be008b3c31a2bf6495c3e282ea3eea7b677e51181801eeb4d0f1",
kit: "d2fa700c0186ed7eb9199700ed5b027078420801d0d09de76954364f94937d7a",
},
collectathon: {
shared: "7bb3e329118f0a2fb174037f6084f14a1392241ac4ba577d4f86babbc1a2e763",
shared: "effdee05cf87ba2eab7f7bb3c09d8b04a9e2f2bfd84f8150c675825ed71f8466",
kit: "d442044c496f13384c098fa60573220cf41f5c0c07ac13ada43c1a5f0f64de72",
},
kart: {
shared: "017719c2db08a143f8a166264b4d5178c6caedc1487c905e42c42accfbd74304",
shared: "f70cdddfd146d3364486479fdff9c2827e302eeb4dc7107f97dedd15617478ca",
kit: "edd8ff2a1a9bb9465eaa6c82ff830b57d75f3b594018c45cc7cf3a6c737bd9c2",
},
minimal: {
shared: "259a65b3b39d48c6e4a57568a900276e31a488416dfc842ff94d9a2821ea7727",
shared: "30f3b611b3660fbc60a111bcdb2ae4f8e921094b015b2d305820ae01b660d448",
kit: "c211a38224fc1b10e6f8a2e3a626bb56ee4f16ef36114e331936a3649d81925f",
},
platformer: {
shared: "050711e6aec4b42dd35933882009e524d10593f1fe86ab8cc46e5dd40c1725c6",
shared: "d90142bef0834e78f4f93f970627cf8dffc7c6da58d79c930dda92a988efe292",
kit: "53f077d2b87719369152286f211dccd7dd721615e9e2dab8584e19b8ef68b1c6",
},
puzzle: {
shared: "487ff9a1353ad28e49308ec5058dc755cfa8e8bb0ffad0ea1b2cfdc053c3fbe7",
shared: "a91fdae593e0d6c75720a21c5a90727f166c3e9e03bf1ee203ad120df53a0e45",
kit: "81db308b4a7c9dbb711555f4b4355d4c0fa3baa6d5d2f3a80327333fe5b39401",
},
racing: {
shared: "aef769ca1cfc034524908ef8ae8222d8bdda12a4d499ec605a42138667c609fc",
shared: "cbb692efd65b9490dfe71b75a86586eb2088f4336dd623a08b93317b1e3ba2b7",
kit: "502ce8d65e38e69bfcec7c4c9981284048809676c6267f2a945534ed6a3f14c9",
},
rain: {
shared: "2ac30555c1b2a99930c7fe3b59f95784e54737ca15cd64b9e57bf983839930b3",
shared: "d5b2311f051cba749eaa483eb980b1a3e4884af26eabe2af9d904aa10f7b25a2",
kit: "f0adf851fb6d0e14acdf903b47a00e3fa41ff88ca5f7086e43ea3a09d4609b96",
},
rts: {
shared: "87e279f98ac41038fe5a870d4bdcd2cdb0f19e99b9923b79286ed9a99b7614ef",
shared: "7c374f7187a2787d1d174e2c22b4bccfbd86bed21275943dc15639095b7b7b24",
kit: "15ae383f30b33a6ca2c3185f51d3365f5fbe71b49f1432a3d6b30c3dc576d669",
},
runner: {
shared: "b7261bb849ddb42f95282dd59e76fab6be51556433b66da39c48cadf1adf9811",
shared: "9a5c61b31b7d6fe228449dda48718cc97a9d91c30dce85d4052236cae968217f",
kit: "b90f44f06df584db1557264c0fb07167adeb0b136c547839884ff1bcddca1158",
},
sailing: {
shared: "8c0d8afe41bc87036b5f885f038b9d4111321710d78143723869d9c4d1c61e09",
shared: "b236051f0db1f52c34ab103afcc312f7dcebd6c94d2d9746c3fd2d27bbb7d345",
kit: "db196dbad5d5b1e1c6b20bac8369c28426f7e2d7dac5e2ee948ec18a79e70cf0",
},
shooter: {
shared: "4fe65b7c7da2779fbbf878d8952da34b253ec0d273866b49fbe28969e4243906",
shared: "380ff86d3c3c4612d7226abec95bb4775e37113f5bb9acc3a0875ed16f43815d",
kit: "0e89ad8f17773794527b8bb153f265b6a7344792498f2a173447a666ecd60cf5",
},
snow: {
shared: "b5e2a1ea614da1442dfbea47227e8c7e945372f5fe7d6c0ace5ea0e62a156a93",
shared: "806bb4c5280625b4110b87c72e8e026d0af3689ad1f293d516985e7f5b6ffc1f",
kit: "4da758c138d7f80b9a4e88903e2c86172c0029bd160da1ebe5f37b72bcfea823",
},
starter: {
shared: "1755088c86fde98617ab77f5addf6ec1415ae6f804e74935ed751106a5b4a60c",
shared: "fd5ade5e3c5fb8a7c8a7bb2ab9f3e744400c6d275f3e7d92e43723b6e109ad63",
kit: "232c5278ee55f8581bf3b2b25c53d8a852a4a1704947ee2a6d22f3756c724a1e",
},
"tower-defense": {
shared: "13f86e288d99c9bcf8276e094627ffd106b4d5be698dca7bbbfa37b2c689ee33",
shared: "c83d3b71375c3651dc67f7a3d216b6dc167fc699e26f6d2aada1fe87559e8f00",
kit: "7fa359a8a390a9bcbb2c832ba4fe66f45129170f387333173625cab93e0c7ba0",
},
// scaffold-pin:end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ problem, not instead of it: read the code the change touches and trace the real
flow end to end, then climb. Two rungs work → take the higher one and move on.

1. **Does this need to exist at all?** Speculative need = skip it, say so in one line. (YAGNI)
2. **Already installed in the engine?** For a new game, search `engine_search_capabilities` for the full request and each concrete mechanic and inspect hits with `engine_capability_detail`; for a change to an existing game, search for that change (and references for each gameplay mechanic) as the existing-game route in `threenative-capabilities` says, without restarting whole-game discovery or planning. Reuse the returned system — a node, convention, helper or subpath that already ships is the answer, not a thing to re-implement. This rung *is* the critical capability gate; keep a change's recorded result rather than treating each edit as a new game. Keep installed systems instead of hand-writing replacements.
2. **Already installed in the engine?** For a new game, search `engine_search_capabilities` for the full request and each concrete mechanic and read every row (a row answers; `engine_capability_detail` is only for a symbol the search did not return); for a change to an existing game, search for that change (and references for each gameplay mechanic) as the existing-game route in `threenative-capabilities` says, without restarting whole-game discovery or planning. Reuse the returned system — a node, convention, helper or subpath that already ships is the answer, not a thing to re-implement. This rung *is* the critical capability gate; keep a change's recorded result rather than treating each edit as a new game. Keep installed systems instead of hand-writing replacements.
3. **Already in this game?** Reuse a helper, type, or pattern that already lives in `src/`. Re-implementing what sits a few files over is the most common slop.
4. **Stdlib or engine convention does it?** `loadAll`, `addInSlices`, `GroundSnap`, an input vector, a physics query — reach for those before writing your own loop.
5. **Already-installed dependency solves it?** Use it; never add a new one for what a few lines can do.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,12 @@ try {
log = readFileSync(logFile, "utf8");
} catch (error) {
deny(
`Discovery gate: cannot read ${path.relative(root, logFile)} (${error.code ?? error.message}), so the engine was not searched in this project. Before you create ${created.join(", ")}, run ${SEARCH} and engine_capability_detail on every hit. Outside this folder: ${BRIDGE}. Opt out with TN_DISCOVERY_GATE=off.`,
`Discovery gate: cannot read ${path.relative(root, logFile)} (${error.code ?? error.message}), so the engine was not searched in this project. Before you create ${created.join(", ")}, run ${SEARCH} and read every row; engine_capability_detail is only for a symbol the search did not return. Outside this folder: ${BRIDGE}. Opt out with TN_DISCOVERY_GATE=off.`,
);
process.exit(0);
}
const since = Date.parse(state.prompt ?? "") || 0;
if (!searchedSince(log, since))
deny(
`Discovery gate: no engine_search_capabilities call since the last user prompt. Before you create ${created.join(", ")}, run ${SEARCH}, then engine_capability_detail on every hit, and use what it returns. Outside this folder: ${BRIDGE}. Opt out with TN_DISCOVERY_GATE=off.`,
`Discovery gate: no engine_search_capabilities call since the last user prompt. Before you create ${created.join(", ")}, run ${SEARCH} and read every row — engine_capability_detail is only for a symbol the search did not return — and use what it returns. Outside this folder: ${BRIDGE}. Opt out with TN_DISCOVERY_GATE=off.`,
);
21 changes: 21 additions & 0 deletions packages/engine-mcp/__tests__/search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,27 @@ describe("threenative-engine-mcp", () => {
expect(searchResults(query, workspaceManifest)[0]?.symbol).toBe(symbol);
});

/**
* A one-word name is also an ordinary English word. Spelled inside a longer request it must not
* displace the mechanic the request is about: `View` is a React rectangle, `scene`, `depth`,
* `velocity` and `connect` are `three/tsl` buffers and a transport. The word has to hold a real
* share of the request, and it has to be rare in the name index, or the row is a wrong lead.
*/
it.each([
["camera follows the player with a third person view", "View"],
["the enemy velocity should push the player back when hit", "velocity"],
["make the water depth fog darker underwater", "depth"],
["make the scene feel foggy and moody", "Scene"],
])("does not answer '%s' with the ordinary word '%s'", (query, symbol) => {
expect(searchResults(query, workspaceManifest)[0]?.symbol).not.toBe(symbol);
});

it("keeps a one-word name that holds the request", () => {
expect(searchResults("add godrays", workspaceManifest)[0]?.symbol).toBe("godrays");
expect(searchResults("enable ssgi", workspaceManifest)[0]?.symbol).toBe("ssgi");
expect(searchResults("use rapier", workspaceManifest)[0]?.symbol).toBe("rapier");
});

/** Spelling `characterbody3d` or `Area3D` as words means the same node as the camel case. */
it.each([
["character body 3d movement", "CharacterBody3D"],
Expand Down
Loading
Loading