From cd409d0e1d4f2f49f5c688a2df8f5455ea008235 Mon Sep 17 00:00:00 2001 From: ewood Date: Tue, 17 Feb 2026 23:05:54 -0500 Subject: [PATCH 1/7] Initial commit of character saving. --- package.json | 3 +- scripts/invalidate-artifacts.ts | 69 ++++ src/ai/design/design-workflow.ts | 105 +---- src/ai/design/game-design-state.ts | 8 +- .../nodes/conversational-agent/prompts.ts | 101 +++++ .../nodes/spec-diff/index.ts | 2 + .../nodes/spec-execute/index.ts | 2 - .../nodes/spec-execute/prompts.ts | 18 +- .../nodes/spec-plan/index.ts | 4 +- src/ai/image-gen/image-gen-service.ts | 272 ++++++++++++ src/ai/memory/checkpoint-memory.ts | 21 +- .../__tests__/simulate-workflow.test.ts | 155 ++++++- .../graphs/runtime-graph/runtime-state.ts | 16 + .../graphs/spec-processing-graph/index.ts | 32 +- .../nodes/extract-instructions/index.ts | 4 +- .../nodes/extract-instructions/validators.ts | 109 +++-- .../__tests__/extract-produced-tokens.test.ts | 209 ++++++++++ .../nodes/extract-produced-tokens/executor.ts | 92 +++++ .../nodes/extract-produced-tokens/index.ts | 78 ++++ .../nodes/extract-produced-tokens/prompts.ts | 97 +++++ .../extract-produced-tokens/validators.ts | 132 ++++++ .../nodes/extract-transitions/executor.ts | 14 +- .../nodes/extract-transitions/planner.ts | 14 +- .../nodes/extract-transitions/prompts.ts | 2 + .../spec-processing-state.ts | 14 + src/ai/simulate/schema.ts | 167 +++++--- src/ai/simulate/simulate-workflow.ts | 387 ++++++++++++++---- src/ai/tools.ts | 31 +- src/api/simulate/handler.ts | 55 +++ src/api/simulate/routes.ts | 38 ++ src/api/simulate/schemas.ts | 39 ++ 31 files changed, 1969 insertions(+), 321 deletions(-) create mode 100644 scripts/invalidate-artifacts.ts create mode 100644 src/ai/image-gen/image-gen-service.ts create mode 100644 src/ai/simulate/graphs/spec-processing-graph/nodes/extract-produced-tokens/__tests__/extract-produced-tokens.test.ts create mode 100644 src/ai/simulate/graphs/spec-processing-graph/nodes/extract-produced-tokens/executor.ts create mode 100644 src/ai/simulate/graphs/spec-processing-graph/nodes/extract-produced-tokens/index.ts create mode 100644 src/ai/simulate/graphs/spec-processing-graph/nodes/extract-produced-tokens/prompts.ts create mode 100644 src/ai/simulate/graphs/spec-processing-graph/nodes/extract-produced-tokens/validators.ts diff --git a/package.json b/package.json index 6881531..229198d 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "pm2:logs:bot": "pm2 logs chaincraft-discord-bot", "pm2:status": "pm2 status", "pm2:monit": "pm2 monit", - "import-game": "npm run build && node ./dist/scripts/import-game.js" + "import-game": "npm run build && node ./dist/scripts/import-game.js", + "invalidate-artifacts": "npm run build && node ./dist/scripts/invalidate-artifacts.js" }, "keywords": [], "author": "", diff --git a/scripts/invalidate-artifacts.ts b/scripts/invalidate-artifacts.ts new file mode 100644 index 0000000..b753d2d --- /dev/null +++ b/scripts/invalidate-artifacts.ts @@ -0,0 +1,69 @@ +#!/usr/bin/env node +import "dotenv/config.js"; + +import { deleteThread } from "#chaincraft/ai/memory/checkpoint-memory.js"; +import { getCachedSpecArtifacts } from "#chaincraft/ai/simulate/simulate-workflow.js"; +import { getCachedDesign } from "#chaincraft/ai/design/design-workflow.js"; +import { getConfig } from "#chaincraft/config.js"; + +function parseArgs() { + const args = process.argv.slice(2); + const parsed: Record = {}; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg.startsWith("--")) { + const key = arg.slice(2); + const next = args[i + 1]; + if (next && !next.startsWith("--")) { + parsed[key] = next; + i++; + } else { + parsed[key] = true; + } + } + } + + return parsed; +} + +async function main() { + const args = parseArgs(); + const gameIdRaw = args["gameId"]; + if (!gameIdRaw || typeof gameIdRaw !== "string") { + throw new Error("Missing required argument --gameId"); + } + + let versionStr = typeof args["version"] === "string" ? (args["version"] as string) : undefined; + + if (!versionStr) { + console.log("[invalidate-artifacts] --version not provided, loading latest design version..."); + const design = await getCachedDesign(gameIdRaw); + const latestVersion = design?.specification?.version; + if (latestVersion === undefined || latestVersion === null) { + throw new Error("Could not determine latest version from cached design; please specify --version"); + } + versionStr = String(latestVersion); + console.log(`[invalidate-artifacts] Resolved latest version=${versionStr}`); + } + + const specKey = `${gameIdRaw}-v${versionStr}`; + const graphType = getConfig("simulation-graph-type"); + + console.log(`[invalidate-artifacts] Target specKey=${specKey}, graphType=${graphType}`); + + const existing = await getCachedSpecArtifacts(specKey); + if (!existing) { + console.log("[invalidate-artifacts] No cached artifacts found (already empty or never generated). Proceeding to delete thread to force regen."); + } else { + console.log("[invalidate-artifacts] Cached artifacts found. Deleting thread to force regeneration on next sim run."); + } + + await deleteThread(specKey, graphType); + console.log("[invalidate-artifacts] Deleted checkpoint thread. Next simulation run will regenerate artifacts."); +} + +main().catch((error) => { + console.error("[invalidate-artifacts] Failed:", error instanceof Error ? error.message : error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/ai/design/design-workflow.ts b/src/ai/design/design-workflow.ts index d39a946..0f118c4 100644 --- a/src/ai/design/design-workflow.ts +++ b/src/ai/design/design-workflow.ts @@ -1,6 +1,5 @@ import "dotenv/config.js"; -import { SystemMessagePromptTemplate } from "@langchain/core/prompts"; import { Checkpoint, CompiledStateGraph } from "@langchain/langgraph"; import { AIMessage, @@ -15,19 +14,13 @@ import { } from "#chaincraft/ai/design/game-design-state.js"; import { gameTitleTag, - imageDesignPrompt, produceFullGameDesignPrompt, - imageGenPrompt, - rawImageGenPrompt, - rawImageNegativePrompt, } from "#chaincraft/ai/design/game-design-prompts.js"; import { getSaver } from "#chaincraft/ai/memory/checkpoint-memory.js"; -import { OverloadedError } from "#chaincraft/ai/error.js"; import { isActiveConversation as _isActiveConversation, registerConversationId, } from "#chaincraft/ai/conversation.js"; -import { imageGenTool, rawImageGenTool } from "#chaincraft/ai/tools.js"; import { GraphCache } from "#chaincraft/ai/graph-cache.js"; import { getConfig } from "#chaincraft/config.js"; import { getConstraintsRegistry } from "./design-data.js"; @@ -36,7 +29,11 @@ import { logSecretStatus, } from "#chaincraft/util/safe-logging.js"; import { createMainDesignGraph } from "./graphs/main-design-graph/index.js"; -import { setupDesignModel } from "#chaincraft/ai/model-config.js"; +import { + generateImageWithDescription, + CARTRIDGE_IMAGE_CONFIG, + RAW_IMAGE_CONFIG, +} from "#chaincraft/ai/image-gen/image-gen-service.js"; // Log safe application startup info logApplicationEvent("design-workflow", "initializing", { @@ -50,18 +47,13 @@ logSecretStatus( ); const graphType = getConfig("design-graph-type"); +console.log(`[design-workflow] Initialized with graphType='${graphType}'`); const designGraphCache = new GraphCache( createDesignGraph, parseInt(process.env.CHAINCRAFT_DESIGN_GRAPH_CACHE_SIZE ?? "100") ); -const imageGenSystemMessage = - SystemMessagePromptTemplate.fromTemplate(imageGenPrompt); - -const rawImageGenSystemMessage = - SystemMessagePromptTemplate.fromTemplate(rawImageGenPrompt); - const gameTitleRegex = new RegExp( `.*?${gameTitleTag}(.*?)${gameTitleTag.replace("<", " - ${summary} - ` - ), - ], - { - agent: "image-design-generator", - workflow: "design", - } - ) - .catch((error) => { - if (error.type && error.type == "overloaded_error") { - throw new OverloadedError(error.message); - } else { - throw error; - } - }); - if (!imageDesign.content) { - throw new Error("Failed to generate image description: no content"); - } + const contextText = `\n${summary}\n`; + const templateVars = { game_title: title }; - // Step 2: Choose the appropriate prompt and tool based on image type - if (imageType === "raw") { - // Use raw image generation - const rawImagePrompt = await rawImageGenSystemMessage.format({ - image_description: imageDesign.content.toString().substring(0, 600), - game_title: title, - }); + const config = imageType === "raw" ? RAW_IMAGE_CONFIG : CARTRIDGE_IMAGE_CONFIG; - const imageUrl = await rawImageGenTool - .invoke(rawImagePrompt.content, { - callbacks: modelWithOptions.getCallbacks(), - metadata: { - agent: "raw-image-generator", - workflow: "design", - }, - negativePrompt: rawImageNegativePrompt, - }) - .catch((error) => { - if (error.type && error.type == "overloaded_error") { - throw new OverloadedError(error.message); - } else { - throw error; - } - }); - if (!imageUrl) { - throw new Error("Failed to generate raw image: no image URL"); - } - return imageUrl; - } else { - // Use legacy cartridge image generation - const imageGenPrompt = await imageGenSystemMessage.format({ - image_description: imageDesign.content.toString().substring(0, 600), - game_title: title, - }); - const imageUrl = await imageGenTool - .invoke(imageGenPrompt.content, { - callbacks: modelWithOptions.getCallbacks(), - metadata: { - agent: "cartridge-image-generator", - workflow: "design", - }, - }) - .catch((error) => { - if (error.type && error.type == "overloaded_error") { - throw new OverloadedError(error.message); - } else { - throw error; - } - }); - if (!imageUrl) { - throw new Error("Failed to generate legacy image: no image URL"); - } - return imageUrl; - } + return generateImageWithDescription(contextText, templateVars, config); } diff --git a/src/ai/design/game-design-state.ts b/src/ai/design/game-design-state.ts index 41ce4fe..22156f8 100644 --- a/src/ai/design/game-design-state.ts +++ b/src/ai/design/game-design-state.ts @@ -91,10 +91,16 @@ export const GameDesignState = Annotation.Root({ // Spec Gen Batching pendingSpecChanges: Annotation({ reducer: (x, y) => { - // If y is an empty array and x exists, this is a clear operation - replace + // If not provided, preserve checkpoint value + if (y === undefined) { + return x || []; + } + + // If explicitly provided as empty, clear if (y.length === 0 && x && x.length > 0) { return []; } + // Otherwise append (normal accumulation) return [...(x || []), ...y]; }, diff --git a/src/ai/design/graphs/main-design-graph/nodes/conversational-agent/prompts.ts b/src/ai/design/graphs/main-design-graph/nodes/conversational-agent/prompts.ts index 7528aa2..165c8fb 100644 --- a/src/ai/design/graphs/main-design-graph/nodes/conversational-agent/prompts.ts +++ b/src/ai/design/graphs/main-design-graph/nodes/conversational-agent/prompts.ts @@ -142,6 +142,77 @@ IMPORTANT: You CANNOT see the generated narrative content - only the skeleton st If a user references specific narrative text that you don't see in the skeleton, ask them to identify which narrative section (by marker name) they're referring to. +## NFT/TOKEN SUPPORT QUESTIONS + +**When to ask about NFT support:** + +Ask about NFT/token support when you've captured enough core game design to understand the game state structure. +This typically happens: +- After defining player state (what data each player has) +- When discussing character progression, items, or upgrades +- After explaining win conditions and scoring +- When the user mentions trading, collecting, or persistence + +**DO NOT ask about NFTs:** +- In the very first message (too early, user hasn't defined the game yet) +- For extremely simple games with no meaningful state (e.g., pure rock-paper-scissors) +- If the user has already declined or said they're not interested in web3 features + +**How to introduce NFT support:** + +Keep it natural and positioned as an optional enhancement. Example phrasing: + +"This game has [character progression / collectible items / player stats] that players will +build over time. Would you like players to be able to save their [characters/items/progress] +as NFTs? This would let them trade these assets or use them across different games." + +**What to ask if they're interested:** + +1. **What to extract**: "Which aspects of [player state] should be extractable as NFTs?" + - Guide them to the meaningful fields (character name, stats, appearance, history) + - Avoid technical fields (IDs, timestamps, session data) + +2. **Mutability**: "Should these fields be able to change during gameplay?" + - Example: "Can a character's backstory evolve as they play?" + - Document which fields are: + - Immutable (name, starting traits) + - Mutable-replace (current level, equipment) + - Mutable-append (history, achievements) + +3. **Import compatibility**: "Should players be able to import saved [characters/items] into new games?" + - If yes: Confirm which fields are required vs. optional + +**When to update the spec with NFT configuration:** + +- Set ${SPEC_UPDATE_TAG} when the user provides NFT extraction preferences +- Include NFT configuration even if partial (can refine later) +- Capture the decision incrementally - don't wait for complete information + +**Example conversation flow:** + +User: "Players have characters with name, class, level, and inventory" +Assistant: [captures player state, then asks] + +"I see players will develop unique characters over time. Would you like players to be +able to save their characters as NFTs? This would let them trade characters or import +them into other compatible games." + +User: "Yes, that sounds cool" +Assistant: "Great! Which character aspects should be extractable as NFTs? I'd suggest: +- Name +- Class +- Level +- Inventory contents + +Should all of these be included, or would you like to exclude any?" + +User: "Include all of those" +Assistant: "Perfect! Should any of these be able to change during gameplay? For example, +level and inventory would probably increase, but name and class might stay fixed?" + +User: "Right, name and class are fixed, but level and inventory can change" +Assistant: "Got it! I'll update the specification with NFT support configured. + ## RESPONSE REQUIREMENTS 1. **Always include a game title** in your response using the format: @@ -266,6 +337,36 @@ export const FEW_SHOT_EXAMPLES = [ assistant: "Absolutely! I'll generate the complete game specification now based on everything we've discussed - all the rules, mechanics, win conditions, and how the trading and market systems work.\n\nA few more details would help me refine it even further in the next iteration:\n1. How many players should the game support? (2, 3-4, or more?)\n2. What's the target game length? (quick 15-min, medium 30-min, or longer?)\n3. What specific medieval goods are being traded? (spices, textiles, metals, grain?)\n\nBut I'm generating the spec now with what we have!\n\nMarket Moguls\n", flags: { specUpdateNeeded: true, metadataUpdateNeeded: false }, explanation: "User explicitly requested spec - generate immediately, questions are for future refinement not blocking" + }, + { + user: "Players have characters with name, race, class, level, and equipment slots", + assistant: "Excellent character structure! I'll capture that player state in the spec. This gives players meaningful progression through levels and equipment.\n\nSince players will be developing unique characters, would you like them to be able to save their characters as NFTs? This would let players trade characters or potentially import them into other compatible games on the platform.\n\nRealm Quest\n", + flags: { specUpdateNeeded: true, metadataUpdateNeeded: false }, + explanation: "User defined player state - capture it, then ask about NFT support since there's meaningful progression" + }, + { + user: "Yes! Players should be able to save characters as NFTs", + assistant: "Great! Let's configure which aspects should be extractable. Based on your character structure, I'd suggest making these fields available for NFT extraction:\n\n- name\n- race\n- class\n- level\n- equipment (what items they have)\n\nShould all of these be included, or would you prefer to exclude any? For example, some games keep level progression internal to that specific game.\n\nRealm Quest", + flags: { specUpdateNeeded: false, metadataUpdateNeeded: false }, + explanation: "User wants NFT support - ask what to extract before updating spec" + }, + { + user: "Include all except equipment - just the character identity and level", + assistant: "Perfect! So NFTs will capture: name, race, class, and level. I'll update the spec with that configuration.\n\nOne more thing: should any of these fields be able to change during gameplay? My guess is name, race, and class are permanent, but level increases as they play. Is that right?\n\nRealm Quest\n", + flags: { specUpdateNeeded: true, metadataUpdateNeeded: false }, + explanation: "User specified exact fields - capture immediately, then ask about mutability" + }, + { + user: "Correct - name, race, and class are fixed, level can increase", + assistant: "Got it! I'll update the NFT configuration:\n- Immutable fields: name, race, class\n- Mutable field: level (increases during play)\n\nPlayers will be able to save their characters at any point, capturing their current level. Should players also be able to import saved characters when starting a new game?\n\nRealm Quest\n", + flags: { specUpdateNeeded: true, metadataUpdateNeeded: false }, + explanation: "User provided mutability info - update spec, ask about import support" + }, + { + user: "Yes, they should be able to import saved characters", + assistant: "Perfect! I'll configure the game to support character import. When a player imports a character NFT, it will initialize their player state with those saved values.\n\nThe NFT system is now fully configured for your game!\n\nRealm Quest\n", + flags: { specUpdateNeeded: true, metadataUpdateNeeded: false }, + explanation: "User confirmed import support - final update to complete NFT configuration" } ]; diff --git a/src/ai/design/graphs/main-design-graph/nodes/spec-diff/index.ts b/src/ai/design/graphs/main-design-graph/nodes/spec-diff/index.ts index e1947f6..69a54b6 100644 --- a/src/ai/design/graphs/main-design-graph/nodes/spec-diff/index.ts +++ b/src/ai/design/graphs/main-design-graph/nodes/spec-diff/index.ts @@ -160,5 +160,7 @@ export async function specDiff(state: typeof GameDesignState.State) { return { specDiff: diffSummary, currentSpec: updatedSpec, + pendingSpecChanges: [], // Clear accumulated changes only after full success + forceSpecGeneration: false, // Reset force flag on successful completion }; } diff --git a/src/ai/design/graphs/main-design-graph/nodes/spec-execute/index.ts b/src/ai/design/graphs/main-design-graph/nodes/spec-execute/index.ts index a957326..9d1f2f3 100644 --- a/src/ai/design/graphs/main-design-graph/nodes/spec-execute/index.ts +++ b/src/ai/design/graphs/main-design-graph/nodes/spec-execute/index.ts @@ -162,8 +162,6 @@ export function createSpecExecute(model: ModelWithOptions) { lastSpecUpdate: new Date().toISOString(), lastSpecMessageCount: state.messages.length, specUpdateNeeded: false, // Reset the flag - pendingSpecChanges: [], // Clear pending plans after execution - forceSpecGeneration: false, // Reset force flag narrativesNeedingUpdate: markers, // Populate markers for narrative generation }; } catch (error) { diff --git a/src/ai/design/graphs/main-design-graph/nodes/spec-execute/prompts.ts b/src/ai/design/graphs/main-design-graph/nodes/spec-execute/prompts.ts index f59c5ab..3ae0728 100644 --- a/src/ai/design/graphs/main-design-graph/nodes/spec-execute/prompts.ts +++ b/src/ai/design/graphs/main-design-graph/nodes/spec-execute/prompts.ts @@ -37,7 +37,8 @@ Use markers for extensive narrative style guidance and examples: - Marker will be expanded with full guidance later `; -export const SYSTEM_PROMPT = `!___ CACHE:spec-execute-guidelines ___! +export const SYSTEM_PROMPT = ` +!___ CACHE:spec-execute-guidelines ___! You are writing a SKELETON game specification that focuses on gameplay requirements. A skeleton spec describes WHAT happens in the game (player experience, outcomes, rules) and uses MARKERS for lengthy narrative guidance. @@ -59,12 +60,27 @@ Write requirements needed for: game setup, player actions, action outcomes, info - When game ends: "First to win 3 rounds wins the match" - Fairness rules: "Players have 90 seconds; timeout = auto-generated weapon" - Narrative requirements: "Narrative must mention all weapons and declare winner clearly" +- Optional NFT support (if applicable): Include an "## NFTs" section listing each NFT content +type as a subsection with bullets indicating production/consumption capability. Example format: + \`\`\` + ## NFTs + + ### Character Data + - Can be produced by this game + - Can be consumed by this game + + ### Item Data + - Can be produced by this game + \`\`\` + Do not describe fields, mechanics, or implementation details ❌ **Exclude (spec-processing will handle):** - Data structures: "Player state has fields: roundsWon, weaponName..." - Validation details: "Weapon name must match regex ^[a-zA-Z0-9 -']+$" - Phase logic: "Transition to RESOLUTION when all submitted OR timer expires" - Algorithms: "Assign R/P/S using weighted randomization with rebalancing..." +- NFT implementation details or save/load flows: if prior messages mention saving/loading characters, only acknowledge support is handled by the engine; do not specify steps, storage, or fields +- State persistence implementation: when/how state is saved or loaded **Quality Standards:** - Be specific: Use exact numbers, not "some", "usually", "about" diff --git a/src/ai/design/graphs/main-design-graph/nodes/spec-plan/index.ts b/src/ai/design/graphs/main-design-graph/nodes/spec-plan/index.ts index 561a20a..cfed334 100644 --- a/src/ai/design/graphs/main-design-graph/nodes/spec-plan/index.ts +++ b/src/ai/design/graphs/main-design-graph/nodes/spec-plan/index.ts @@ -5,7 +5,7 @@ * Extracts summary, playerCount, and detailed change plan from conversation. */ -import { BaseMessage } from "@langchain/core/messages"; +import { BaseMessage, HumanMessage } from "@langchain/core/messages"; import { SystemMessagePromptTemplate } from "@langchain/core/prompts"; import { StructuredOutputParser } from "@langchain/core/output_parsers"; import { ModelWithOptions } from "#chaincraft/ai/model-config.js"; @@ -45,7 +45,7 @@ function extractRelevantMessages( */ function formatConversationHistory(messages: BaseMessage[]): string { return messages.map(msg => { - const role = msg._getType() === 'human' ? 'User' : 'Design Assistant'; + const role = msg instanceof HumanMessage ? 'User' : 'Design Assistant'; return `${role}: ${msg.content}`; }).join('\n\n'); } diff --git a/src/ai/image-gen/image-gen-service.ts b/src/ai/image-gen/image-gen-service.ts new file mode 100644 index 0000000..465808e --- /dev/null +++ b/src/ai/image-gen/image-gen-service.ts @@ -0,0 +1,272 @@ +import { SystemMessage, HumanMessage } from "@langchain/core/messages"; +import { LeonardoAPIWrapper, LeonardoConfig } from "#chaincraft/ai/tools.js"; +import { OverloadedError } from "#chaincraft/ai/error.js"; +import { setupDesignModel } from "#chaincraft/ai/model-config.js"; + +// ── Config types ────────────────────────────────────────────────────────── + +/** + * Full configuration for an image generation run. + * Combines Leonardo API settings with prompt configuration. + */ +export interface ImageGenConfig { + /** Leonardo API settings. */ + leonardo: LeonardoConfig; + /** Prompt template sent to Leonardo. Uses {placeholders} for variable substitution. */ + promptTemplate: string; + /** Optional negative prompt for Leonardo. */ + negativePrompt?: string; + /** + * Optional LLM system prompt for a two-step flow. + * When provided, an LLM first generates an image description from the context, + * then that description is injected into the promptTemplate as {image_description}. + * When omitted, context is injected directly into the promptTemplate (single-step). + */ + descriptionSystemPrompt?: string; + /** Max characters for the image description (default: 600). */ + descriptionMaxChars?: number; +} + +// ── Preset configs ──────────────────────────────────────────────────────── + +const LEO_API_KEY = process.env.CHAINCRAFT_GAMEBUILDER_LEO_IMAGEGEN_API_KEY; + +/** + * Cartridge-style game box art (with LoRA). + * Two-step: LLM describes the image → Leonardo generates a cartridge image. + */ +export const CARTRIDGE_IMAGE_CONFIG: ImageGenConfig = { + leonardo: { + modelId: "b24e16ff-06e3-43eb-8d33-4416c2d75876", // Leonardo Creative + width: 1024, + height: 768, + numImages: 1, + userElements: [{ userLoraId: 59955, weight: 0.8 }], + apiKey: LEO_API_KEY, + }, + promptTemplate: `A 4:3 landscape image of a gray plastic video game cartridge from the 1990s for +the fictional console 'CHAINCRAFT.' The cartridge is wide, centered, and +front-facing. It floats on a flat, solid dark gray background, with no shadows +or gradients. The plastic is worn with scratches and grooves. + +A large, retro-style label with chipped edges covers most of the front. The label +features colorful 1990s-style game cover art inspired by: + +{image_description} + +The game title {game_title} is clearly printed at the top of the label in bold text. +The title is fully visible, with no distortion. Below the label is an embossed "CHAINCRAFT" logo +molded into the plastic cartridge.`, + descriptionSystemPrompt: `You are a concept artist for a game design company. You are tasked with creating images for a game that has been designed by the game design team. + +Come up with a description of an image that represents the game design suitable for the image on the box of the game. +Don't just specify an image that depicts players playing the game or the game components such as boards, cards, etc... +Instead, think of an image that captures the essence of the game, the feeling that the game is meant to evoke, and what +makes it unique and fun. Make the image interesting and engaging, something that would make someone want to pick up the game and play it. +Your task is to describe the image to be created in detail so that an generative AI can create the image. +Make sure to include all the details that are important to the image, e.g. the setting, the characters, the mood, the colors, etc... + +Please limit the description to 600 characters.`, +}; + +/** + * Raw retro game art (no LoRA, for publishing). + * Two-step: LLM describes the image → Leonardo generates raw art. + */ +export const RAW_IMAGE_CONFIG: ImageGenConfig = { + leonardo: { + modelId: "de7d3faf-762f-48e0-b3b7-9d0ac3a3fcf3", // Phoenix 1.0 + width: 768, + height: 576, + numImages: 1, + apiKey: LEO_API_KEY, + }, + promptTemplate: `Retro video game art illustration, 1980s-1990s style with vibrant hand-drawn aesthetic and slightly faded vintage appearance. +IMPORTANT: The title "{game_title}" must be prominently displayed in bold lettering near the top of the image, +fully integrated into the design. Typography style, color, and effects must authentically match the game's +specific genre and emotional tone. + +Full bleed artwork depicting: {image_description} + +Title "{game_title}" rendered in period-accurate typography with thematically-driven styling. +Classic colorful 1980s-1990s gaming art style with dramatic composition, bold colors, and authentic +period-appropriate details.`, + negativePrompt: `no text, missing title, blank title area, wrong font style, modern fonts, yellow gradient text, orange gradient text, generic title colors, border, frame, box, cartridge, case, package, product, margin, white space, black border, edge border, outline, container, packaging, 3D render, game case, rectangular frame, modern digital art, photo realistic, UI elements, watermark, logo, breathing room, padding, inset`, + descriptionSystemPrompt: `You are a concept artist for a game design company. You are tasked with creating images for a game that has been designed by the game design team. + +Come up with a description of an image that represents the game design suitable for the image on the box of the game. +Don't just specify an image that depicts players playing the game or the game components such as boards, cards, etc... +Instead, think of an image that captures the essence of the game, the feeling that the game is meant to evoke, and what +makes it unique and fun. Make the image interesting and engaging, something that would make someone want to pick up the game and play it. +Your task is to describe the image to be created in detail so that an generative AI can create the image. +Make sure to include all the details that are important to the image, e.g. the setting, the characters, the mood, the colors, etc... + +Please limit the description to 600 characters.`, +}; + +/** + * Token image generation (direct prompt, no LLM description step). + * Single-step: token description + data are injected directly into the prompt. + */ +export const TOKEN_IMAGE_CONFIG: ImageGenConfig = { + leonardo: { + modelId: "de7d3faf-762f-48e0-b3b7-9d0ac3a3fcf3", // Phoenix 1.0 + width: 832, + height: 1216, + numImages: 1, + apiKey: LEO_API_KEY, + }, + promptTemplate: ` +Data for the image to be generated: +{token_description} + +{token_data} + +vintage retro video game trading card full card illustration, vertical +2:3, bold illustrated border inspired by 1980s and 1990s video game +box art, chunky painted frame with bright primary color accents, bold +geometric corner details, slightly worn printed cardboard texture on +frame edges only, vivid limited color palette feel, symmetrical card +layout, the upper two-thirds of the card features a painted portrait +of the character, item, ability, or achievement described above, +rendered in vintage retro video game box art +illustration style, vivid saturated colors, dramatic but flat +studio-style lighting with no heavy shadows, clean cel-shaded edges, +the portrait blends naturally into a subtle bold color atmospheric +background with no hard borders or inset frames around the portrait +area, seamlessly flush with generous room on all sides, the lower +third is a clean flat darker footer panel matching the card palette, +completely empty, no text, no icons; decorative border surrounds +card exterior only, no text or symbols anywhere, no harsh shadows +on card interior, square portrait composition +`, + negativePrompt: ` + text, letters, words, numbers, stats, icons, logos, watermarks, UI +labels, stat boxes, inner border, inset frame, raised edge, bevel, +stroke around image zone, recessed panel, shadow around rectangle, +frame within frame, dark vignette, lighting gradient in center zone, +glow inside image area, texture inside image zone, shadows in art +area, realistic photography, hyper-detailed rendering, photorealistic +materials, blurry, noise, low quality, asymmetric design, multiple +characters, busy background, detailed background, ground or floor +visible + + `, + // No descriptionSystemPrompt → single-step direct prompt +}; + +// ── Service functions ───────────────────────────────────────────────────── + +/** + * Generate an image using a two-step flow: + * 1. LLM produces a text description from contextText + * 2. Description is injected into config.promptTemplate and sent to Leonardo + * + * Use this for design images (box art) where creative interpretation is needed. + * + * @param contextText Source material for the LLM (e.g., game summary) + * @param templateVars Additional variables to substitute in the prompt template (e.g., {game_title}) + * @param config Image generation configuration (must include descriptionSystemPrompt) + * @returns Image URL + */ +export async function generateImageWithDescription( + contextText: string, + templateVars: Record, + config: ImageGenConfig, +): Promise { + if (!config.descriptionSystemPrompt) { + throw new Error( + "generateImageWithDescription requires a descriptionSystemPrompt in config" + ); + } + + // Step 1: LLM generates image description + const modelWithOptions = await setupDesignModel(); + const maxChars = config.descriptionMaxChars ?? 600; + + const imageDesign = await modelWithOptions + .invokeWithMessages( + [ + new SystemMessage(config.descriptionSystemPrompt), + new HumanMessage(contextText), + ], + { + agent: "image-description-generator", + workflow: "image-gen", + } + ) + .catch((error) => { + if (error.type && error.type === "overloaded_error") { + throw new OverloadedError(error.message); + } + throw error; + }); + + if (!imageDesign.content) { + throw new Error("Failed to generate image description: no content"); + } + + const description = imageDesign.content.toString().substring(0, maxChars); + + // Step 2: Build Leonardo prompt and generate + const allVars = { ...templateVars, image_description: description }; + return _generateWithLeonardo(allVars, config, modelWithOptions.getCallbacks()); +} + +/** + * Generate an image using a single-step direct prompt flow. + * Template variables are injected directly into the prompt template and sent to Leonardo. + * + * Use this for token images where you already have concrete description + data. + * + * @param templateVars Variables to substitute in the prompt template (e.g., {token_description}, {token_data}) + * @param config Image generation configuration + * @returns Image URL + */ +export async function generateImageDirect( + templateVars: Record, + config: ImageGenConfig, +): Promise { + return _generateWithLeonardo(templateVars, config); +} + +// ── Internal helpers ────────────────────────────────────────────────────── + +async function _generateWithLeonardo( + templateVars: Record, + config: ImageGenConfig, + callbacks?: any[], +): Promise { + // Simple string interpolation for {placeholder} vars — avoids the complexity + // of SystemMessagePromptTemplate which returns a message object + let prompt = config.promptTemplate; + for (const [key, value] of Object.entries(templateVars)) { + prompt = prompt.split(`{${key}}`).join(value); + } + + console.debug("[ImageGen] Final prompt (%d chars): %s", prompt.length, prompt); + + const tool = new LeonardoAPIWrapper(config.leonardo); + + const imageUrl = await tool + .invoke(prompt, { + callbacks, + metadata: { + agent: "image-generator", + workflow: "image-gen", + }, + negativePrompt: config.negativePrompt, + }) + .catch((error) => { + if (error.type && error.type === "overloaded_error") { + throw new OverloadedError(error.message); + } + throw error; + }); + + if (!imageUrl) { + throw new Error("Image generation failed: no image URL returned"); + } + + return imageUrl; +} diff --git a/src/ai/memory/checkpoint-memory.ts b/src/ai/memory/checkpoint-memory.ts index c0427d5..4121329 100644 --- a/src/ai/memory/checkpoint-memory.ts +++ b/src/ai/memory/checkpoint-memory.ts @@ -27,10 +27,9 @@ let isInitialized = false; // Get database backend from environment variable function getDatabaseBackend(): DatabaseBackend { const dbType = process.env.CHECKPOINT_DB_TYPE?.toLowerCase(); - if (dbType === "postgres" || dbType === "postgresql") { - return "postgres"; - } - return "sqlite"; // Default to SQLite + const backend = (dbType === "postgres" || dbType === "postgresql") ? "postgres" : "sqlite"; + console.log(`[checkpoint-memory] getDatabaseBackend: CHECKPOINT_DB_TYPE=${dbType || 'undefined'}, selected=${backend}`); + return backend; } async function initialize() { @@ -64,8 +63,10 @@ async function getOrCreateDatabase(graphType: string): Promise { if (backend === "sqlite") { state.dbPath = path.join(process.cwd(), "data", `${graphType}-memory.db`); + console.log(`[checkpoint-memory] Created database state for graphType=${graphType}, backend=sqlite, dbPath=${state.dbPath}`); } else { state.connectionString = process.env.POSTGRES_CONNECTION_STRING; + console.log(`[checkpoint-memory] Created database state for graphType=${graphType}, backend=postgres, hasConnectionString=${!!state.connectionString}`); } databases.set(graphType, state); @@ -154,11 +155,13 @@ export async function getSaver( export async function listThreadIds(graphType: string): Promise { await initialize(); + console.log(`[checkpoint-memory] listThreadIds called for graphType=${graphType}`); const db = await getOrCreateDatabase(graphType); const saver = await getSaver('list-threads', graphType); // Ensure saver is initialized const ids = new Set(); if (db.backend === "postgres") { + console.log(`[checkpoint-memory] Using postgres backend to list threads`); // For PostgreSQL: query distinct thread_ids directly (much more efficient than loading checkpoints) const pool = (saver as any).pool as Pool; const result = await pool.query( @@ -168,16 +171,22 @@ export async function listThreadIds(graphType: string): Promise { ids.add(row.thread_id); } } else { + console.log(`[checkpoint-memory] Using sqlite backend to list threads from dbPath=${db.dbPath}`); // For SQLite: use limited checkpoint list - for await (const checkpoint of saver.list({}, { limit: 100 })) { + let count = 0; + for await (const checkpoint of saver.list({}, {})) { const threadId = checkpoint.config?.configurable?.thread_id; if (threadId) { ids.add(threadId); + count++; } } + console.log(`[checkpoint-memory] Scanned ${count} checkpoints, found ${ids.size} unique thread_ids`); } - return Array.from(ids).sort(); + const result = Array.from(ids).sort(); + console.log(`[checkpoint-memory] listThreadIds result: ${result.length} threads`); + return result; } export async function deleteThread( diff --git a/src/ai/simulate/__tests__/simulate-workflow.test.ts b/src/ai/simulate/__tests__/simulate-workflow.test.ts index 2f79cca..755b0d2 100644 --- a/src/ai/simulate/__tests__/simulate-workflow.test.ts +++ b/src/ai/simulate/__tests__/simulate-workflow.test.ts @@ -4,6 +4,8 @@ import { initializeSimulation, PlayerStates, processAction, + produceToken, + SpecArtifacts, } from "#chaincraft/ai/simulate/simulate-workflow.js"; import { setConfig } from "#chaincraft/config.js"; import { fail } from "assert"; @@ -29,7 +31,7 @@ describe("Simulation Workflow", () => { sessionId, // Unique session ID undefined, // gameId not needed when passing spec directly undefined, // version not needed when passing spec directly - initialGameSpec // Pass spec directly for testing + { overrideSpecification: initialGameSpec } ); expect(gameRules).toBeDefined(); @@ -95,3 +97,154 @@ function validatePlayerStates(playerId: string, playerStates: PlayerStates) { expect(playerState).toBeDefined(); // Private messages are optional - only set when game/instructions specify them } + +describe("Token Production", () => { + setConfig("simulation-graph-type", "test-token-production"); + + const tokenSessionId = `token-session-${Date.now()}-${Math.random().toString(36).substring(7)}`; + const testGameId = "test-character-game"; + const testPlayerId = `player-${crypto.randomUUID()}`; + + // Pre-generated artifacts for a character-based game with token support + const characterGameArtifacts: SpecArtifacts = { + gameRules: "A character progression game where players create and develop characters.", + stateSchema: JSON.stringify([ + { name: "turnNumber", path: "game", type: "number", purpose: "Current turn" }, + { name: "name", path: "player", type: "string", purpose: "Character name" }, + { name: "class", path: "player", type: "string", purpose: "Character class" }, + { name: "level", path: "player", type: "number", purpose: "Character level" }, + { name: "experience", path: "player", type: "number", purpose: "Experience points" }, + { name: "currentHealth", path: "player", type: "number", purpose: "Current health (ephemeral)" }, + { name: "ready", path: "player", type: "boolean", purpose: "Player ready status" }, + ]), + stateTransitions: JSON.stringify({ + phases: ["init", "CHARACTER_CREATION"], + phaseMetadata: [ + { phase: "init", requiresPlayerInput: false }, + { phase: "CHARACTER_CREATION", requiresPlayerInput: true }, + ], + transitions: [ + { + id: "initialize_game", + fromPhase: "init", + toPhase: "CHARACTER_CREATION", + humanSummary: "Initialize game with players", + preconditions: [] + } + ] + }), + playerPhaseInstructions: { + "CHARACTER_CREATION": "Players create their characters by providing name and class.", + }, + transitionInstructions: { + "initialize_game": JSON.stringify({ + stateDelta: [ + { op: "setForAllPlayers", field: "character", value: null }, + { op: "setForAllPlayers", field: "status", value: "awaiting_character_selection" }, + { op: "setForAllPlayers", field: "ready", value: false } + ], + publicMessage: "Game initialized. Create your character!", + privateMessages: {}, + phaseKey: "CHARACTER_CREATION" + }) + }, + producedTokensConfiguration: JSON.stringify({ + tokens: [{ + tokenType: "character", + description: "Persistent player character with class, level, and progression", + tokenSource: "player", + fields: ["name", "class", "level", "experience"] + }] + }), + }; + + test("should create simulation with token configuration", async () => { + const { gameRules } = await createSimulation( + tokenSessionId, + testGameId, + 1, + { + preGeneratedArtifacts: characterGameArtifacts, + } + ); + + expect(gameRules).toBeDefined(); + expect(gameRules).toContain("character"); + }, 30000); + + test("should initialize and populate player state", async () => { + const { playerStates } = await initializeSimulation(tokenSessionId, [testPlayerId]); + + expect(playerStates.size).toBe(1); + expect(playerStates.has(testPlayerId)).toBe(true); + }, 30000); + + test("should produce character token with correct data", async () => { + // Directly inject character data into the state for testing token production + // This bypasses the need for real LLM calls and focuses on testing produceToken() + const saver = await import("#chaincraft/ai/memory/checkpoint-memory.js").then(m => m.getSaver(tokenSessionId, "test-token-production")); + const config = { configurable: { thread_id: tokenSessionId } }; + const checkpoint = await saver.getTuple(config); + const state = checkpoint?.checkpoint.channel_values as any; + + // Parse and update game state with character data + const gameState = JSON.parse(state.gameState); + const playerMapping = JSON.parse(state.playerMapping || "{}"); + const alias = Object.keys(playerMapping)[0]; // "player1" + const uuid = playerMapping[alias]; // Get the UUID from the alias + + gameState.players[uuid] = { + ...gameState.players[uuid], + name: "Aria", + class: "Warrior", + level: 5, + experience: 100, + currentHealth: 100, // ephemeral - should not be in token + ready: true // ephemeral - should not be in token + }; + + state.gameState = JSON.stringify(gameState); + + // Save updated state back + await saver.put(config, checkpoint.checkpoint, checkpoint.metadata); + + // Now produce the token + const token = await produceToken( + tokenSessionId, + "character", + testPlayerId + ); + + // Validate token structure + expect(token).toBeDefined(); + expect(token.metadata).toBeDefined(); + expect(token.data).toBeDefined(); + + // Validate metadata + expect(token.metadata.tokenType).toBe("character"); + expect(token.metadata.gameId).toBe(testGameId); + expect(token.metadata.version).toBe("1"); + + // Validate data contains only the specified fields (not ephemeral ones) + expect(token.data.name).toBe("Aria"); + expect(token.data.class).toBe("Warrior"); + expect(token.data.level).toBe(5); + expect(token.data.experience).toBe(100); + + // Should NOT include ephemeral fields + expect(token.data.currentHealth).toBeUndefined(); + expect(token.data.ready).toBeUndefined(); + }, 30000); + + test("should reject token production for non-existent token type", async () => { + await expect( + produceToken(tokenSessionId, "invalid-token-type", testPlayerId) + ).rejects.toThrow("Token type 'invalid-token-type' is not produced by this game"); + }, 30000); + + test("should reject token production for non-existent player", async () => { + await expect( + produceToken(tokenSessionId, "character", "non-existent-player") + ).rejects.toThrow("Player 'non-existent-player' not found in game state"); + }, 30000); +}); diff --git a/src/ai/simulate/graphs/runtime-graph/runtime-state.ts b/src/ai/simulate/graphs/runtime-graph/runtime-state.ts index 06fd609..7247861 100644 --- a/src/ai/simulate/graphs/runtime-graph/runtime-state.ts +++ b/src/ai/simulate/graphs/runtime-graph/runtime-state.ts @@ -12,6 +12,17 @@ import { Annotation } from "@langchain/langgraph"; export type RuntimeStateType = typeof RuntimeState.State; export const RuntimeState = Annotation.Root({ + // Metadata about the game specification used + gameId: Annotation({ + reducer: (_, y) => y, + default: () => "", + }), + + gameSpecificationVersion: Annotation({ + reducer: (_, y) => y, + default: () => 0, + }), + // Inputs players: Annotation({ reducer: (x, y) => [...new Set([...x, ...y])], @@ -53,6 +64,11 @@ export const RuntimeState = Annotation.Root({ default: () => ({}), }), + producedTokensConfiguration: Annotation({ + reducer: (_, y) => y, + default: () => "", + }), + // Runtime state gameState: Annotation({ reducer: (_, y) => y, diff --git a/src/ai/simulate/graphs/spec-processing-graph/index.ts b/src/ai/simulate/graphs/spec-processing-graph/index.ts index 5039b58..0f035c3 100644 --- a/src/ai/simulate/graphs/spec-processing-graph/index.ts +++ b/src/ai/simulate/graphs/spec-processing-graph/index.ts @@ -4,7 +4,8 @@ * Transforms game specification into runtime artifacts: * 1. extract_schema - Generate state schema * 2. extract_transitions - Identify phase transitions - * 3. generate_instructions - Create phase-specific instructions + * 3. extract_instructions - Create phase-specific instructions + * 4. extract_produced_tokens - Identify persistent tokens to produce */ import { StateGraph, START, END } from "@langchain/langgraph"; @@ -13,6 +14,7 @@ import { SpecProcessingState } from "#chaincraft/ai/simulate/graphs/spec-process import { schemaExtractionConfig } from "#chaincraft/ai/simulate/graphs/spec-processing-graph/nodes/extract-schema/index.js"; import { transitionsExtractionConfig } from "#chaincraft/ai/simulate/graphs/spec-processing-graph/nodes/extract-transitions/index.js"; import { instructionsExtractionConfig } from "#chaincraft/ai/simulate/graphs/spec-processing-graph/nodes/extract-instructions/index.js"; +import { producedTokensExtractionConfig } from "#chaincraft/ai/simulate/graphs/spec-processing-graph/nodes/extract-produced-tokens/index.js"; import { createValidationNode } from "#chaincraft/ai/simulate/graphs/spec-processing-graph/nodes/validate-transitions/index.js"; import { createExtractionSubgraph } from "#chaincraft/ai/simulate/graphs/spec-processing-graph/node-factories.js"; @@ -32,6 +34,7 @@ export async function createSpecProcessingGraph( const schemaSubgraph = createExtractionSubgraph(schemaExtractionConfig); const transitionsSubgraph = createExtractionSubgraph(transitionsExtractionConfig); const instructionsSubgraph = createExtractionSubgraph(instructionsExtractionConfig); + const producedTokensSubgraph = createExtractionSubgraph(producedTokensExtractionConfig); // Create validation node for transitions const validationNode = createValidationNode(); @@ -50,6 +53,10 @@ export async function createSpecProcessingGraph( const result = await instructionsSubgraph.invoke(state, config); return result; }); + workflow.addNode("extract_produced_tokens", async (state, config) => { + const result = await producedTokensSubgraph.invoke(state, config); + return result; + }); // Define flow with validation error checks // Route START directly to schema extraction @@ -65,16 +72,19 @@ export async function createSpecProcessingGraph( const hasTransitions = state.stateTransitions && state.stateTransitions.length > 0; const hasPlayerPhaseInstructions = state.playerPhaseInstructions && Object.keys(state.playerPhaseInstructions || {}).length > 0; const hasTransitionInstructions = state.transitionInstructions && Object.keys(state.transitionInstructions || {}).length > 0; + const hasProducedTokens = state.producedTokensConfiguration && state.producedTokensConfiguration.length > 0; if (!hasSchema) return "schema"; if (!hasTransitions) return atomic ? "schema" : "transitions"; if (!hasPlayerPhaseInstructions || !hasTransitionInstructions) return atomic ? "schema" : "instructions"; + if (!hasProducedTokens) return atomic ? "schema" : "produced_tokens"; return "end"; }, { schema: "extract_schema" as any, transitions: "extract_transitions" as any, instructions: "extract_instructions" as any, + produced_tokens: "extract_produced_tokens" as any, end: END, } ); @@ -113,8 +123,24 @@ export async function createSpecProcessingGraph( } ); - // After instructions: always end - workflow.addEdge("extract_instructions" as any, END); + // After instructions: check for errors before continuing to tokens + workflow.addConditionalEdges( + "extract_instructions" as any, + (state) => { + if (state.instructionsValidationErrors && state.instructionsValidationErrors.length > 0) { + console.error("[SpecProcessingGraph] Instructions extraction failed validation, stopping pipeline"); + return "end"; + } + return "continue"; + }, + { + continue: "extract_produced_tokens" as any, + end: END, + } + ); + + // After produced tokens: always end + workflow.addEdge("extract_produced_tokens" as any, END); console.log("[SpecProcessingGraph] Graph compiled successfully"); return workflow.compile({ checkpointer }); diff --git a/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-instructions/index.ts b/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-instructions/index.ts index 72bb0e4..d2c9c0e 100644 --- a/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-instructions/index.ts +++ b/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-instructions/index.ts @@ -8,7 +8,7 @@ import { instructionsExecutorNode } from "./executor.js"; import { validatePlanCompleteness, validateJsonParseable, - validateInitializationCompleteness, + // validateInitializationCompleteness, validateActionRequiredSet, validateNarrativeMarkers, validateArtifactStructure, @@ -39,7 +39,7 @@ export const instructionsExtractionConfig: NodeConfig = { validatePathStructure, validateArtifactStructure, validatePhaseConnectivity, - validateInitializationCompleteness, + // validateInitializationCompleteness, validateActionRequiredSet, validateNarrativeMarkers, validateInitialStatePreconditions, diff --git a/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-instructions/validators.ts b/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-instructions/validators.ts index 1294764..8d8082a 100644 --- a/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-instructions/validators.ts +++ b/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-instructions/validators.ts @@ -361,9 +361,9 @@ function validateStateDelta( } /** - * Validate initialization completeness - all fields used in preconditions must be initialized + * Validate precondition coverage - all fields used in preconditions must be written by some stateDelta op */ -export async function validateInitializationCompleteness( +export async function validatePreconditionsCanPass( state: SpecProcessingStateType, store: BaseStore, threadId: string @@ -384,15 +384,6 @@ export async function validateInitializationCompleteness( ? JSON.parse(executionOutput) : executionOutput; - // Router context fields computed at runtime - const ROUTER_CONTEXT_FIELDS = new Set([ - 'allPlayersCompletedActions', - 'playersCount', - 'playerCount', - 'allPlayersReady', - 'anyPlayerReady', - ]); - // Parse transitions let transitions: any; try { @@ -421,62 +412,70 @@ export async function validateInitializationCompleteness( }); if (preconditionFields.size === 0) return []; + // Router context fields computed at runtime + const ROUTER_CONTEXT_FIELDS = new Set([ + 'allPlayersCompletedActions', + 'playersCount', + 'playerCount', + 'allPlayersReady', + 'anyPlayerReady', + ]); - // Find init transition - const initTransition = transitionList.find((t: any) => t.fromPhase === 'init'); - if (!initTransition) { - return []; - } + // Collect all fields written by any stateDelta (transitions + player actions) + const writtenFields = new Set(); + + const addPath = (path: string) => { + if (!path || typeof path !== 'string') return; + writtenFields.add(path); + const normalizedPath = path + .replace(/\.\{\{[^}]+\}\}\./g, '[*].') + .replace(/\.player\d+\./g, '[*].') + .replace(/players\.\*/g, 'players[*]') + .replace(/\[\d+\]/g, ''); + writtenFields.add(normalizedPath); + }; - // Get init instructions - const initInstructions = artifact.transitions[initTransition.id]; - if (!initInstructions) { - return [`Init transition "${initTransition.id}" has no instructions in artifact`]; + // Transition stateDelta ops + if (artifact.transitions) { + Object.values(artifact.transitions).forEach((t: any) => { + (t?.stateDelta || []).forEach((op: any) => { + addPath(op.path); + addPath(op.fromPath); + addPath(op.toPath); + if (op.field && op.op === 'setForAllPlayers') { + addPath(`players[*].${op.field}`); + } + }); + }); } - // Extract initialized fields - const initializedFields = new Set(); - if (initInstructions.stateDelta && Array.isArray(initInstructions.stateDelta)) { - initInstructions.stateDelta.forEach((op: any) => { - if (op.path && typeof op.path === 'string') { - let normalizedPath = op.path - .replace(/\.\{\{[^}]+\}\}\./g, '[*].') - .replace(/\.player\d+\./g, '[*].') - .replace(/players\.\*/g, 'players[*]'); - - initializedFields.add(op.path); - initializedFields.add(normalizedPath); - - const baseArrayPath = op.path.replace(/\[\d+\]$/, ''); - if (baseArrayPath !== op.path) { - initializedFields.add(baseArrayPath); - const normalizedBaseArrayPath = baseArrayPath - .replace(/\.\{\{[^}]+\}\}\./g, '[*].') - .replace(/\.player\d+\./g, '[*].') - .replace(/players\.\*/g, 'players[*]'); - initializedFields.add(normalizedBaseArrayPath); + // Player action stateDelta ops + Object.values(artifact.playerPhases || {}).forEach((phase: any) => { + (phase?.playerActions || []).forEach((action: any) => { + (action?.stateDelta || []).forEach((op: any) => { + addPath(op.path); + addPath(op.fromPath); + addPath(op.toPath); + if (op.field && op.op === 'setForAllPlayers') { + addPath(`players[*].${op.field}`); } - } + }); }); - } + }); - // Check uninitialized fields - const uninitializedFields: string[] = []; + // Check coverage + const missingFields: string[] = []; preconditionFields.forEach((field: string) => { if (ROUTER_CONTEXT_FIELDS.has(field)) return; - - if (initializedFields.has(field)) return; - - const normalizedField = field.replace(/\[\d+\]/g, '.*').replace(/\.\d+\./g, '.*.'); - if (initializedFields.has(normalizedField)) return; - - uninitializedFields.push(field); + if (writtenFields.has(field)) return; + const normalizedField = field.replace(/\[\d+\]/g, '').replace(/\.\d+\./g, '.'); + if (writtenFields.has(normalizedField)) return; + missingFields.push(field); }); - uninitializedFields.forEach((field: string) => { + missingFields.forEach((field: string) => { errors.push( - `Field "${field}" is used in transition preconditions but is never initialized by the init transition. ` + - `Add a stateDelta operation in the init transition to set ${field} to an appropriate initial value.` + `Field "${field}" is used in transition preconditions but is never written by any stateDelta operation.` ); }); diff --git a/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-produced-tokens/__tests__/extract-produced-tokens.test.ts b/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-produced-tokens/__tests__/extract-produced-tokens.test.ts new file mode 100644 index 0000000..4edbcc8 --- /dev/null +++ b/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-produced-tokens/__tests__/extract-produced-tokens.test.ts @@ -0,0 +1,209 @@ +/** + * Test for extract-produced-tokens node + * + * Validates that the node can: + * 1. Detect when tokens are appropriate + * 2. Extract valid produced token configurations + * 3. Validate fields exist in state schema + * 4. Handle games without tokens + */ + +import { describe, expect, it } from "@jest/globals"; +import { producedTokensExtractionConfig } from "../index.js"; +import { createExtractionSubgraph } from "#chaincraft/ai/simulate/graphs/spec-processing-graph/node-factories.js"; +import { InMemoryStore } from "@langchain/langgraph"; +import { createArtifactCreationGraphConfig } from "#chaincraft/ai/graph-config.js"; +import { TokenSource } from "#chaincraft/ai/simulate/schema.js"; + +const CHARACTER_GAME_SPEC = ` +# Fantasy Adventure RPG + +## Overview +A persistent character progression game where players create and develop unique characters. + +## Game Rules + +### Character Creation +- Players create a character with: + - Name (player chosen) + - Class (warrior, mage, rogue) + - Starting level: 1 + - Starting health: 100 + +### Gameplay +- Characters explore dungeons and fight monsters +- Gain experience points from battles +- Level up when reaching experience thresholds +- Unlock new abilities as they progress + +### Persistence +- Characters persist between game sessions +- Players can import/export their characters +- Characters can be traded or gifted to other players +`; + +const CHARACTER_SCHEMA = JSON.stringify([ + { name: "currentDungeon", path: "game", type: "string", purpose: "Current dungeon level" }, + { name: "monstersDefeated", path: "game", type: "number", purpose: "Total monsters defeated" }, + { name: "name", path: "player", type: "string", purpose: "Character name" }, + { name: "class", path: "player", type: "string", purpose: "Character class" }, + { name: "level", path: "player", type: "number", purpose: "Character level" }, + { name: "experience", path: "player", type: "number", purpose: "Experience points" }, + { name: "currentHealth", path: "player", type: "number", purpose: "Current health" }, + { name: "ready", path: "player", type: "boolean", purpose: "Player ready status" }, +]); + +const RPS_SPEC = ` +# Rock-Paper-Scissors Tournament + +## Overview +A quick tournament version of Rock-Paper-Scissors for 3 players. + +## Game Rules + +### Setup +- Three players join the game +- Game lasts 3 rounds + +### Gameplay +1. Each round, all players simultaneously submit a move: Rock, Paper, or Scissors +2. Once all players submit, the round is evaluated +3. Score is calculated based on wins/losses + +### Victory +- Player with highest score after 3 rounds wins +`; + +const RPS_SCHEMA = JSON.stringify([ + { name: "roundNumber", path: "game", type: "number", purpose: "Current round" }, + { name: "totalRounds", path: "game", type: "number", purpose: "Total rounds" }, + { name: "currentMove", path: "player", type: "string", purpose: "Player's move this round" }, + { name: "score", path: "player", type: "number", purpose: "Player's cumulative score" }, + { name: "ready", path: "player", type: "boolean", purpose: "Player ready status" }, +]); + +describe("Extract Produced Tokens Node", () => { + it("should create token configuration for character-based game", async () => { + const subgraph = createExtractionSubgraph(producedTokensExtractionConfig); + + const inputState = { + gameSpecification: CHARACTER_GAME_SPEC, + stateSchema: CHARACTER_SCHEMA, + }; + + console.log("Extracting token config from character game..."); + const result = await subgraph.invoke( + inputState, + createArtifactCreationGraphConfig("test-token-char", new InMemoryStore()) + ); + + // Validate token configuration exists + expect(result.producedTokensConfiguration).toBeDefined(); + const tokenConfig = JSON.parse(result.producedTokensConfiguration!); + + console.log("\n=== Token Configuration ==="); + console.log(JSON.stringify(tokenConfig, null, 2)); + + // Should have tokens object with array + expect(tokenConfig.tokens).toBeDefined(); + expect(Array.isArray(tokenConfig.tokens)).toBe(true); + expect(tokenConfig.tokens.length).toBeGreaterThan(0); + + // Get the first token (should be character-related) + const characterToken = tokenConfig.tokens[0]; + + // Validate token structure + expect(characterToken).toBeDefined(); + expect(characterToken.tokenType).toBeDefined(); + expect(characterToken.description).toBeDefined(); + expect(characterToken.tokenSource).toBe(TokenSource.Player); + expect(Array.isArray(characterToken.fields)).toBe(true); + expect(characterToken.fields.length).toBeGreaterThan(0); + + // Should include character attributes but not ephemeral state + const fields = characterToken.fields; + + // Should include character identity/progression + const hasCharacterFields = fields.some((f: string) => + ['name', 'class', 'level'].includes(f) + ); + expect(hasCharacterFields).toBe(true); + + // Should NOT include ephemeral state + expect(fields).not.toContain('ready'); + expect(fields).not.toContain('currentHealth'); // Current health is ephemeral + + console.log(`✓ Character token has ${fields.length} fields: ${fields.join(', ')}`); + }, 60000); + + it("should return empty configuration for ephemeral match game", async () => { + const subgraph = createExtractionSubgraph(producedTokensExtractionConfig); + + const inputState = { + gameSpecification: RPS_SPEC, + stateSchema: RPS_SCHEMA, + }; + + console.log("\nExtracting token config from RPS game..."); + const result = await subgraph.invoke( + inputState, + createArtifactCreationGraphConfig("test-token-rps", new InMemoryStore()) + ); + + // Validate token configuration exists but is empty + expect(result.producedTokensConfiguration).toBeDefined(); + const tokenConfig = JSON.parse(result.producedTokensConfiguration!); + + console.log("\n=== Token Configuration ==="); + console.log(JSON.stringify(tokenConfig, null, 2)); + + // Should have tokens object with empty array - no persistent assets in RPS + expect(tokenConfig.tokens).toBeDefined(); + expect(Array.isArray(tokenConfig.tokens)).toBe(true); + expect(tokenConfig.tokens.length).toBe(0); + + console.log("✓ No tokens generated for ephemeral game"); + }, 60000); + + it("should validate fields exist in schema", async () => { + const subgraph = createExtractionSubgraph(producedTokensExtractionConfig); + + // Create schema with only a few fields + const limitedSchema = JSON.stringify([ + { name: "name", path: "player", type: "string", purpose: "Player name" }, + { name: "score", path: "player", type: "number", purpose: "Player score" }, + ]); + + const inputState = { + gameSpecification: CHARACTER_GAME_SPEC, + stateSchema: limitedSchema, + }; + + console.log("\nExtracting token config with limited schema..."); + const result = await subgraph.invoke( + inputState, + createArtifactCreationGraphConfig("test-token-validation", new InMemoryStore()) + ); + + // Should still succeed (might return empty or only use available fields) + expect(result.producedTokensConfiguration).toBeDefined(); + const tokenConfig = JSON.parse(result.producedTokensConfiguration!); + + console.log("\n=== Token Configuration ==="); + console.log(JSON.stringify(tokenConfig, null, 2)); + + // If tokens are created, all fields should exist in the limited schema + expect(tokenConfig.tokens).toBeDefined(); + expect(Array.isArray(tokenConfig.tokens)).toBe(true); + for (const config of tokenConfig.tokens) { + const { tokenType, fields } = config; + if (Array.isArray(fields)) { + fields.forEach((field: string) => { + const fieldExists = ['name', 'score'].includes(field); + expect(fieldExists).toBe(true); + console.log(`✓ Token '${tokenType}' field '${field}' validated in schema`); + }); + } + } + }, 60000); +}); diff --git a/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-produced-tokens/executor.ts b/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-produced-tokens/executor.ts new file mode 100644 index 0000000..1a77f5e --- /dev/null +++ b/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-produced-tokens/executor.ts @@ -0,0 +1,92 @@ +/** + * Produced Tokens Executor Node + * + * Analyzes game specification to determine which tokens this game produces + */ + +import { ModelWithOptions } from "#chaincraft/ai/model-config.js"; +import { SpecProcessingStateType } from "../../spec-processing-state.js"; +import { SystemMessagePromptTemplate } from "@langchain/core/prompts"; +import { extractProducedTokensTemplate } from "./prompts.js"; +import { ProducedTokensArtifactSchema, ProducedTokensArtifactSchemaJson } from "#chaincraft/ai/simulate/schema.js"; +import { + GraphConfigWithStore, + incrementAttemptCount, + putToStore, +} from "../../node-shared.js"; + +/** + * Format state fields for prompt + */ +function formatStateFields(stateSchemaJson: string): string { + if (!stateSchemaJson) { + return "No state fields available"; + } + + try { + const fields = JSON.parse(stateSchemaJson); + if (!Array.isArray(fields)) { + return "Invalid state schema format"; + } + + const gameFields = fields + .filter((f: any) => f.path === "game") + .map((f: any) => ` - ${f.name} (${f.type}): ${f.purpose || ""}`) + .join("\n"); + + const playerFields = fields + .filter((f: any) => f.path === "player") + .map((f: any) => ` - ${f.name} (${f.type}): ${f.purpose || ""}`) + .join("\n"); + + return `Game State Fields:\n${gameFields || " (none)"}\n\nPlayer State Fields:\n${playerFields || " (none)"}`; + } catch (error) { + return "Error parsing state schema"; + } +} + +export function producedTokensExecutorNode(model: ModelWithOptions) { + return async ( + state: SpecProcessingStateType, + config?: GraphConfigWithStore + ): Promise> => { + console.debug("[produced_tokens_executor] Analyzing specification for produced tokens"); + + const store = config?.store; + const threadId = config?.configurable?.thread_id || "default"; + + // Format state fields from schema + const stateFields = formatStateFields(state.stateSchema); + + // Generate produced tokens extraction + const executorPrompt = SystemMessagePromptTemplate.fromTemplate(extractProducedTokensTemplate); + const executorSystemMessage = await executorPrompt.format({ + gameSpecification: state.gameSpecification, + stateFields, + outputSchema: JSON.stringify(ProducedTokensArtifactSchemaJson, null, 2), + }); + + const executorOutput = await model.invokeWithSystemPrompt( + executorSystemMessage.content as string, + undefined, + { + agent: "produced-tokens-executor", + workflow: "spec-processing", + }, + ProducedTokensArtifactSchema + ); + + console.debug("[produced_tokens_executor] Extraction complete"); + + // Store raw output in store (not checkpointed) + // With structured output, executorOutput is the parsed object directly + const contentString = JSON.stringify(executorOutput); + + await putToStore(store, ["producedTokens", "execution", "output"], threadId, contentString); + + // Track attempt count in store + await incrementAttemptCount(store, "producedTokens", "execution", threadId); + + return {}; + }; +} diff --git a/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-produced-tokens/index.ts b/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-produced-tokens/index.ts new file mode 100644 index 0000000..65601d2 --- /dev/null +++ b/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-produced-tokens/index.ts @@ -0,0 +1,78 @@ +/** + * Produced Tokens Extraction Configuration + * + * Exports node configuration for produced tokens extraction with executor-only pattern. + * + * Analyzes game specification to determine which tokens this game produces + * and which fields should be included in those tokens. + */ + +import { + setupSpecSchemaModel, +} from "#chaincraft/ai/model-config.js"; +import { producedTokensExecutorNode } from "#chaincraft/ai/simulate/graphs/spec-processing-graph/nodes/extract-produced-tokens/executor.js"; +import { + validateProducedTokensFields, + extractProducedTokensConfig, +} from "#chaincraft/ai/simulate/graphs/spec-processing-graph/nodes/extract-produced-tokens/validators.js"; +import { + getFromStore, + NodeConfig, +} from "#chaincraft/ai/simulate/graphs/spec-processing-graph/node-shared.js"; + +export const producedTokensExtractionConfig: NodeConfig = { + namespace: "producedTokens", + + // No planning needed - direct extraction + planner: undefined, + + executor: { + node: producedTokensExecutorNode, + model: await setupSpecSchemaModel(), + validators: [validateProducedTokensFields], // Structure validated by Zod schema + }, + + maxAttempts: { + plan: 0, // No planning phase + execution: 1, + }, + + commit: async (store, state, threadId) => { + if (!store) { + throw new Error( + "[produced_tokens_extraction_config] Store not configured - cannot commit data" + ); + } + + // Retrieve executor output + let executorOutput; + try { + executorOutput = await getFromStore( + store, + ["producedTokens", "execution", "output"], + threadId + ); + } catch (error) { + // Executor never ran or failed validation, return empty configuration + return { + producedTokensConfiguration: JSON.stringify({ tokens: [] }), + }; + } + + // Executor output is already the full artifact with tokens array + // Just validate it parses correctly + try { + JSON.parse(executorOutput); + } catch (error) { + console.warn("[commit] Failed to parse executor output, returning empty config"); + return { + producedTokensConfiguration: JSON.stringify({ tokens: [] }), + }; + } + + // Return the full artifact as-is + return { + producedTokensConfiguration: executorOutput, + }; + }, +}; diff --git a/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-produced-tokens/prompts.ts b/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-produced-tokens/prompts.ts new file mode 100644 index 0000000..00bdede --- /dev/null +++ b/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-produced-tokens/prompts.ts @@ -0,0 +1,97 @@ +/** + * Prompt templates for produced tokens extraction + */ + +export const extractProducedTokensTemplate = ` +!___ CACHE:design-planner ___! +You are analyzing a game specification to determine which tokens this game should produce. + +# Game Specification +{gameSpecification} +!___ END-CACHE ___! + +# Available State Fields +{stateFields} + +# Output Schema +Your response must conform to this JSON schema: + +{outputSchema} + + +# Task +Analyze the game specification and return a JSON object containing produced token configurations. + +## Guidelines + +1. **When to Create Token Configuration:** + - Only if NFT or Token creation is explicitly mentioned in the specification (e.g. "players can mint an NFT of their character") + - If no token creation is mentioned, return: {{ "tokens": [] }} + +2. **What to Include in Tokens:** + - Only include fields that: + * Are meaningful identifiers or attributes + * Represent permanent characteristics + * Would be valuable on their own + - Exclude: + * Temporary state (actionRequired, ready, waiting) + * System fields (playerId, timestamp) + * Ephemeral game state (currentTurn, lastAction) + +3. **Token Source:** + - Use "game" for shared/global assets (rare items, global achievements) + - Use "player" for player-specific assets (characters, personal items, player achievements) + +4. **Field Validation:** + - Only reference fields that exist in the state schema + - For "game" source, use fields from game state (without "game." prefix) + - For "player" source, use fields from player state (without "players.*." prefix) + +# Examples + +## Example 1: Character-Based Game +Spec: "players create characters with name, class, and level that can be minted as NFTs" +Fields: name, class, level, health, ready + +Output: +{{ + "tokens": [ + {{ + "tokenType": "character", + "description": "Player character with class and progression", + "tokenSource": "player", + "fields": ["name", "class", "level"] + }} + ] +}} + +## Example 2: No Token Game +Spec: "3-player Rock-Paper-Scissors tournament" +Fields: currentMove, score, ready + +Output: +{{ "tokens": [] }} + +## Example 3: Multiple Token Types +Spec: "players build characters and can mint achievement NFTs when they complete dungeons" +Fields (player): name, class, level, ready +Fields (game): dungeonName, completionDate + +Output: +{{ + "tokens": [ + {{ + "tokenType": "character", + "description": "Player character", + "tokenSource": "player", + "fields": ["name", "class", "level"] + }}, + {{ + "tokenType": "achievement", + "description": "Dungeon completion achievement", + "tokenSource": "game", + "fields": ["dungeonName", "completionDate"] + }} + ] +}} +`; diff --git a/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-produced-tokens/validators.ts b/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-produced-tokens/validators.ts new file mode 100644 index 0000000..9740682 --- /dev/null +++ b/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-produced-tokens/validators.ts @@ -0,0 +1,132 @@ +/** + * Validation functions for produced tokens extraction + */ + +import { SpecProcessingStateType } from "#chaincraft/ai/simulate/graphs/spec-processing-graph/spec-processing-state.js"; +import { getFromStore } from "#chaincraft/ai/simulate/graphs/spec-processing-graph/node-shared.js"; +import { extractSchemaFields } from "#chaincraft/ai/simulate/graphs/spec-processing-graph/schema-utils.js"; +import { BaseStore } from "@langchain/langgraph"; +import { TokenSource } from "#chaincraft/ai/simulate/schema.js"; + +/** + * Parse executor output to extract produced tokens configuration JSON + */ +export function extractProducedTokensConfig(executorOutput: string): any[] { + try { + // With structured output, the content should already be JSON + const parsed = JSON.parse(executorOutput); + // Extract tokens array from wrapper object + if (parsed && typeof parsed === 'object' && 'tokens' in parsed && Array.isArray(parsed.tokens)) { + return parsed.tokens; + } + return []; + } catch (error) { + console.warn("[validator] Failed to parse produced tokens configuration:", error); + return []; + } +} + +/** + * Get available fields from state schema by source type + * Uses extractSchemaFields to get field paths, then filters by source and strips prefixes + */ +function getAvailableFields(stateSchema: string, source: TokenSource): Set { + const availableFields = new Set(); + + try { + const schema = JSON.parse(stateSchema); + const allFields = extractSchemaFields(schema); + + // Filter by source and strip prefix + const prefix = source === TokenSource.Game ? "game." : "players."; + + for (const fieldPath of allFields) { + if (fieldPath.startsWith(prefix)) { + // Strip prefix to get bare field name + const fieldName = fieldPath.substring(prefix.length); + availableFields.add(fieldName); + } + } + } catch (error) { + console.warn("[validator] Failed to parse state schema:", error); + } + + return availableFields; +} + +/** + * Validate produced tokens configuration fields exist in state schema + */ +export async function validateProducedTokensFields( + state: SpecProcessingStateType, + store: BaseStore, + threadId: string +): Promise { + const errors: string[] = []; + + const executorOutput = await getFromStore(store, ["producedTokens", "execution", "output"], threadId); + + if (!executorOutput) { + errors.push("No executor output found"); + return errors; + } + + // Extract produced tokens configuration + const tokenConfigs = extractProducedTokensConfig(executorOutput); + + // If no tokens configured, that's valid (game might not need tokens) + if (tokenConfigs.length === 0) { + console.debug("[validator] No tokens configured - skipping field validation"); + return errors; + } + + // Validate each token configuration + for (const config of tokenConfigs) { + if (!config || typeof config !== 'object') { + errors.push(`Invalid token configuration: ${JSON.stringify(config)}`); + continue; + } + + const { tokenType, tokenSource, fields, description } = config as any; + + // Validate required properties + if (!tokenType) { + errors.push(`Token configuration missing tokenType`); + continue; + } + + if (!description) { + errors.push(`Token type '${tokenType}' missing description`); + } + + if (!tokenSource || !Object.values(TokenSource).includes(tokenSource)) { + errors.push(`Token type '${tokenType}' has invalid or missing tokenSource (must be 'game' or 'player')`); + continue; + } + + if (!Array.isArray(fields) || fields.length === 0) { + errors.push(`Token type '${tokenType}' has no fields specified`); + continue; + } + + // Get available fields for this source + const availableFields = getAvailableFields(state.stateSchema, tokenSource); + + if (availableFields.size === 0) { + errors.push(`No ${tokenSource} fields available in state schema for token type '${tokenType}'`); + continue; + } + + // Validate each field exists in schema + for (const field of fields) { + if (!availableFields.has(field)) { + errors.push( + `Token type '${tokenType}' references field '${field}' not found in ${tokenSource} state. ` + + `Available fields: ${Array.from(availableFields).join(', ')}` + ); + } + } + } + + return errors; +} diff --git a/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-transitions/executor.ts b/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-transitions/executor.ts index c8e3970..53e2dcf 100644 --- a/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-transitions/executor.ts +++ b/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-transitions/executor.ts @@ -57,7 +57,19 @@ export function transitionsExecutorNode(model: ModelWithOptions) { // Extract fields from schema for explicit field list const schemaFields = JSON.parse(String(state.stateSchema ?? "[]")); const availableFields = extractSchemaFields(schemaFields); - const fieldsListForPrompt = Array.from(availableFields).sort().map(f => ` • ${f}`).join('\n'); + // Exclude message/winner fields from precondition candidates to avoid gating on UI text or derived winners + const filteredFields = Array.from(availableFields).filter((f) => { + const disallowed = new Set([ + "game.publicMessage", + "game.winningPlayers", + "players.privateMessage", + "players[*].privateMessage", + "players.isGameWinner", + "players[*].isGameWinner", + ]); + return !disallowed.has(f); + }); + const fieldsListForPrompt = filteredFields.sort().map(f => ` • ${f}`).join('\n'); const computedContextForPrompt = formatComputedContextForPrompt(); // Generate transitions from plan diff --git a/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-transitions/planner.ts b/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-transitions/planner.ts index 933abe0..c6bd0b1 100644 --- a/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-transitions/planner.ts +++ b/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-transitions/planner.ts @@ -69,7 +69,19 @@ export function transitionsPlannerNode(model: ModelWithOptions) { // Extract fields from schema for explicit field list const schemaFields = JSON.parse(String(state.stateSchema ?? "[]")); const availableFields = extractSchemaFields(schemaFields); - const fieldsListForPrompt = Array.from(availableFields).sort().map(f => ` • ${f}`).join('\n'); + // Exclude message/winner fields from precondition candidates to avoid gating on UI text or derived winners + const filteredFields = Array.from(availableFields).filter((f) => { + const disallowed = new Set([ + "game.publicMessage", + "game.winningPlayers", + "players.privateMessage", + "players[*].privateMessage", + "players.isGameWinner", + "players[*].isGameWinner", + ]); + return !disallowed.has(f); + }); + const fieldsListForPrompt = filteredFields.sort().map(f => ` • ${f}`).join('\n'); const computedContextForPrompt = formatComputedContextForPrompt(); // Generate planner analysis diff --git a/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-transitions/prompts.ts b/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-transitions/prompts.ts index e17ad74..d8ebce2 100644 --- a/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-transitions/prompts.ts +++ b/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-transitions/prompts.ts @@ -177,6 +177,7 @@ ONLY reference fields from this explicit list: {computedContextFields} ⛔ If a field is not in the list above, you CANNOT reference it. +⛔ Do NOT use message fields (public or private) or winner flags in preconditions. Use dedicated readiness flags or game.gameEnded instead, and set gameEnded when you set winners. !___ END-CACHE ___! Now analyze the game specification and produce your transitions plan following the format specified above. @@ -372,6 +373,7 @@ ONLY reference fields from this list in JsonLogic \`var\` expressions: {computedContextFields} ⛔ If a field is not in the list, you CANNOT use it. +⛔ Do NOT use message fields (public or private) or winner flags in preconditions. Use readiness flags or game.gameEnded instead, and set gameEnded when you set winners. !___ END-CACHE ___! !___ CACHE:artifacts-executor ___! diff --git a/src/ai/simulate/graphs/spec-processing-graph/spec-processing-state.ts b/src/ai/simulate/graphs/spec-processing-graph/spec-processing-state.ts index d582e4a..f31f0b0 100644 --- a/src/ai/simulate/graphs/spec-processing-graph/spec-processing-state.ts +++ b/src/ai/simulate/graphs/spec-processing-graph/spec-processing-state.ts @@ -49,6 +49,11 @@ export const SpecProcessingState = Annotation.Root({ default: () => ({}), }), + producedTokensConfiguration: Annotation({ + reducer: (_, y) => y, + default: () => "", + }), + // Example state for schema generation exampleState: Annotation({ reducer: (_, y) => y, @@ -82,4 +87,13 @@ export const SpecProcessingState = Annotation.Root({ }, default: () => undefined, }), + + producedTokensValidationErrors: Annotation({ + reducer: (x, y) => { + if (y === null) return null; // explicit clear + if (y === undefined) return x; // not mentioned, keep existing + return [...(x || []), ...y]; // accumulate new errors + }, + default: () => undefined, + }), }); diff --git a/src/ai/simulate/schema.ts b/src/ai/simulate/schema.ts index 7a269e1..513e317 100644 --- a/src/ai/simulate/schema.ts +++ b/src/ai/simulate/schema.ts @@ -29,19 +29,19 @@ c) would break the game or violate fairness without this specific private messag .nullable() .optional() .describe( - "Whether the player is currently allowed to take actions. If omitted, defaults to match actionRequired. Only set explicitly for games with optional actions." + "Whether the player is currently allowed to take actions. If omitted, defaults to match actionRequired. Only set explicitly for games with optional actions.", ), actionRequired: z .boolean() .default(false) .describe( - "If true, the game cannot proceed to the next turn or phase until this player takes an action." + "If true, the game cannot proceed to the next turn or phase until this player takes an action.", ), isGameWinner: z .boolean() .default(false) .describe( - "Whether this player has won the game. Set to true when player meets victory conditions. Used to compute game.winningPlayers array." + "Whether this player has won the game. Set to true when player meets victory conditions. Used to compute game.winningPlayers array.", ), }); @@ -51,7 +51,7 @@ export const baseGameStateSchema = z.object({ currentPhase: z .string() .describe( - "Current phase (round, turn, etc.) of the game (must match a phase from transitions artifact)" + "Current phase (round, turn, etc.) of the game (must match a phase from transitions artifact)", ), gameEnded: z .boolean() @@ -73,7 +73,9 @@ export const baseGameStateSchema = z.object({ timestamp: z.string().describe("ISO timestamp when error occurred"), }) .nullish() - .describe("Error state if game encountered a fatal error (omit or set null when no error)"), + .describe( + "Error state if game encountered a fatal error (omit or set null when no error)", + ), publicMessage: z .string() .optional() @@ -81,7 +83,7 @@ export const baseGameStateSchema = z.object({ winningPlayers: z .array(z.string()) .describe( - "Array of player IDs who have won the game (empty if no winners yet)" + "Array of player IDs who have won the game (empty if no winners yet)", ), }) .describe(`Game-level state containing all shared game progress fields`), @@ -96,29 +98,32 @@ const baseSchemaJson = zodToJsonSchema(baseGameStateSchema, "gameState"); // Post-process to use patternProperties for player keys // This ensures the AI generates player keys like "player1", "player2", etc. // rather than arbitrary keys, which helps with consistent mapping -if (baseSchemaJson && typeof baseSchemaJson === 'object' && - 'properties' in baseSchemaJson && - baseSchemaJson.properties && - typeof baseSchemaJson.properties === 'object' && - 'players' in baseSchemaJson.properties) { +if ( + baseSchemaJson && + typeof baseSchemaJson === "object" && + "properties" in baseSchemaJson && + baseSchemaJson.properties && + typeof baseSchemaJson.properties === "object" && + "players" in baseSchemaJson.properties +) { const playersSchema = (baseSchemaJson.properties as any).players; - if (playersSchema && typeof playersSchema === 'object') { + if (playersSchema && typeof playersSchema === "object") { // Convert from additionalProperties to patternProperties with player[1-9][0-9]* pattern // This enforces player1, player2, ... (starting at 1, not 0) - if ('additionalProperties' in playersSchema) { + if ("additionalProperties" in playersSchema) { const playerStateSchema = playersSchema.additionalProperties; // Preserve the AI's ability to add custom fields to player state // by ensuring additionalProperties is allowed on the player object itself - if (typeof playerStateSchema === 'object' && playerStateSchema !== null) { + if (typeof playerStateSchema === "object" && playerStateSchema !== null) { // Make sure the player state schema allows additional properties // so the AI can extend it with game-specific fields - if (!('additionalProperties' in playerStateSchema)) { + if (!("additionalProperties" in playerStateSchema)) { (playerStateSchema as any).additionalProperties = true; } } delete playersSchema.additionalProperties; playersSchema.patternProperties = { - '^player[1-9][0-9]*$': playerStateSchema // player1, player2, ..., player10, etc. (NOT player0) + "^player[1-9][0-9]*$": playerStateSchema, // player1, player2, ..., player10, etc. (NOT player0) }; playersSchema.additionalProperties = false; // Reject keys that don't match pattern } @@ -144,7 +149,7 @@ const JsonLogicValidator = z ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Unsupported JsonLogic operations: ${unsupportedOps.join( - ", " + ", ", )}. Only standard json-logic-js operations are allowed: ==, !=, >, <, >=, <=, and, or, !, if, +, -, *, /, %, max, min, map, filter, all, none, some, merge, in, cat, substr, var, missing, missing_some, log. Custom operations: allPlayers, anyPlayer, lookup, length (for dynamic array/object access and string/array length)`, }); } @@ -155,18 +160,18 @@ export const TransitionPreconditionSchema = z.object({ .string() .describe("Stable id for the precondition, e.g. 'allSubmitted'"), logic: JsonLogicValidator.describe( - "JsonLogic predicate object or null for non-deterministic/custom checks" + "JsonLogic predicate object or null for non-deterministic/custom checks", ), deterministic: z .boolean() .describe( - "Whether this predicate can be evaluated deterministically by the router" + "Whether this predicate can be evaluated deterministically by the router", ), explain: z .string() .max(500) .describe( - "One-line explanation of the predicate purpose for auditing/debugging. Aim for under 200 chars but may use up to 500 for complex mechanics." + "One-line explanation of the predicate purpose for auditing/debugging. Aim for under 200 chars but may use up to 500 for complex mechanics.", ), }); @@ -185,13 +190,13 @@ export const TransitionSchema = z.object({ checkedFields: z .array(z.string()) .describe( - "Exact dot-path fields the router should inspect; allow simple '[*]' wildcard" + "Exact dot-path fields the router should inspect; allow simple '[*]' wildcard", ), // Preconditions expressed as JsonLogic objects and metadata preconditions: z .array(TransitionPreconditionSchema) .describe( - "Array of preconditions. Prefer deterministic JsonLogic predicates when possible." + "Array of preconditions. Prefer deterministic JsonLogic predicates when possible.", ), humanSummary: z .string() @@ -211,13 +216,13 @@ export const TransitionsArtifactSchema = z.object({ z .string() .describe( - 'An identifier for a game state such as "bidding, playing, day/night phase, scoring". Phases may be visited multiple times during gameplay' - ) + 'An identifier for a game state such as "bidding, playing, day/night phase, scoring". Phases may be visited multiple times during gameplay', + ), ), phaseMetadata: z .array(PhaseMetadataSchema) .describe( - "Metadata for each phase indicating whether it requires player input" + "Metadata for each phase indicating whether it requires player input", ), transitions: z .array(TransitionSchema) @@ -232,7 +237,7 @@ export type PhaseMetadata = z.infer; export type TransitionsArtifact = z.infer; export const TransitionsArtifactSchemaJson = zodToJsonSchema( TransitionsArtifactSchema, - "TransitionsArtifact" + "TransitionsArtifact", ); // ============================================================================ @@ -259,13 +264,13 @@ export const MechanicsGuidanceSchema = z.object({ rules: z .array(z.string()) .describe( - "Ordered list of game rules/mechanics to apply (e.g., ['Rock beats scissors', 'Scissors beats paper', 'Paper beats rock'])" + "Ordered list of game rules/mechanics to apply (e.g., ['Rock beats scissors', 'Scissors beats paper', 'Paper beats rock'])", ), computation: z .string() .optional() .describe( - "Description of what needs to be computed/decided using these rules" + "Description of what needs to be computed/decided using these rules", ), }); @@ -276,7 +281,7 @@ export const RngConfigSchema = z.object({ operations: z .array(z.string()) .describe( - "List of random operations needed (e.g., ['event_type', 'severity_roll', 'affected_player'])" + "List of random operations needed (e.g., ['event_type', 'severity_roll', 'affected_player'])", ), guidance: z .string() @@ -292,7 +297,7 @@ export const PreconditionCheckSchema = z.object({ id: z .string() .describe( - "Stable identifier for this check (e.g., 'wrongPhase', 'invalidChoice')" + "Stable identifier for this check (e.g., 'wrongPhase', 'invalidChoice')", ), logic: z .any() @@ -300,7 +305,7 @@ export const PreconditionCheckSchema = z.object({ errorMessage: z .string() .describe( - "Error message to return if this check fails (logic evaluates to false)" + "Error message to return if this check fails (logic evaluates to false)", ), }); @@ -308,7 +313,7 @@ export const ValidationConfigSchema = z.object({ checks: z .array(PreconditionCheckSchema) .describe( - "Ordered array of precondition checks. First check that fails determines the error message returned." + "Ordered array of precondition checks. First check that fails determines the error message returned.", ), }); @@ -320,20 +325,22 @@ export const PlayerActionInstructionSchema = z.object({ actionName: z.string().describe("Human-readable action name"), // Optional validation - validation: ValidationConfigSchema.nullable().optional().describe( - "JsonLogic preconditions and error messages" - ), + validation: ValidationConfigSchema.nullable() + .optional() + .describe("JsonLogic preconditions and error messages"), // Optional mechanics guidance - mechanicsGuidance: MechanicsGuidanceSchema.nullable().optional().describe( - "Game rules/mechanics for LLM to apply (only if action involves game logic)" - ), + mechanicsGuidance: MechanicsGuidanceSchema.nullable() + .optional() + .describe( + "Game rules/mechanics for LLM to apply (only if action involves game logic)", + ), // State changes (may contain {{templates}}) stateDelta: z .array(StateDeltaOpSchema) .describe( - "Array of stateDelta operations, may contain {{template}} variables" + "Array of stateDelta operations, may contain {{template}} variables", ), // Messages (may contain {{templates}}) @@ -354,20 +361,20 @@ export const AutomaticTransitionInstructionSchema = z.object({ transitionName: z.string().describe("Human-readable transition name"), // Optional mechanics guidance (for computing winners, outcomes, etc.) - mechanicsGuidance: MechanicsGuidanceSchema.nullable().optional().describe( - "Game rules/mechanics for LLM to apply" - ), + mechanicsGuidance: MechanicsGuidanceSchema.nullable() + .optional() + .describe("Game rules/mechanics for LLM to apply"), // Optional RNG configuration - rngConfig: RngConfigSchema.nullable().optional().describe( - "Random number generation requirements" - ), + rngConfig: RngConfigSchema.nullable() + .optional() + .describe("Random number generation requirements"), // State changes (may contain {{templates}}) stateDelta: z .array(StateDeltaOpSchema) .describe( - "Array of stateDelta operations, may contain {{template}} variables" + "Array of stateDelta operations, may contain {{template}} variables", ), // Messages (may contain {{templates}}) @@ -404,13 +411,13 @@ export const InstructionsArtifactSchema = z.object({ playerPhases: z .record(PlayerPhaseInstructionsSchema) .describe( - "Map of phase name to instructions for phases that require player input" + "Map of phase name to instructions for phases that require player input", ), transitions: z .record(AutomaticTransitionInstructionSchema) .describe( - "Map of transition ID to instructions for all automatic transitions" + "Map of transition ID to instructions for all automatic transitions", ), metadata: z.object({ @@ -447,25 +454,69 @@ export type InstructionsArtifact = z.infer; // Instruction JSON schemas for prompt injection export const PlayerActionInstructionSchemaJson = zodToJsonSchema( PlayerActionInstructionSchema, - "PlayerActionInstruction" + "PlayerActionInstruction", ); export const AutomaticTransitionInstructionSchemaJson = zodToJsonSchema( AutomaticTransitionInstructionSchema, - "AutomaticTransitionInstruction" + "AutomaticTransitionInstruction", ); export const PlayerPhaseInstructionsSchemaJson = zodToJsonSchema( PlayerPhaseInstructionsSchema, - "PlayerPhaseInstructions" + "PlayerPhaseInstructions", ); export const InstructionsArtifactSchemaJson = zodToJsonSchema( InstructionsArtifactSchema, - "InstructionsArtifact" + "InstructionsArtifact", +); + +// ============================================================================ +// TOKEN CONFIGURATION ARTIFACT SCHEMAS +// ============================================================================ +export enum TokenSource { + Game = "game", + Player = "player", +} + +export const ProducedTokenConfigurationSchema = z.object({ + tokenType: z + .string() + .describe("Token type name, e.g. 'character', 'item', 'achievement'"), + description: z.string().describe("Description of what this token represents"), + tokenSource: z + .nativeEnum(TokenSource) + .describe( + "The state root that this token is generated from (e.g. game state or player state)", + ), + fields: z + .array(z.string()) + .describe("List of state fields to include in the token"), +}); + +export type ProducedTokenConfiguration = z.infer< + typeof ProducedTokenConfigurationSchema +>; + +export const ProducedTokensArtifactSchema = z.object({ + tokens: z + .array(ProducedTokenConfigurationSchema) + .describe( + "Array of token configurations for tokens that can be generated/saved during gameplay", + ), +}); + +export type ProducedTokensArtifact = z.infer; + +export const ProducedTokensArtifactSchemaJson = zodToJsonSchema( + ProducedTokensArtifactSchema, + "ProducedTokensArtifact", ); /** * Serialize schema (JSON Schema or legacy format) */ -export function serializeSchema(schema: JSONSchemaObject | SchemaField[]): string { +export function serializeSchema( + schema: JSONSchemaObject | SchemaField[], +): string { return JSON.stringify(schema); } @@ -475,12 +526,12 @@ export function serializeSchema(schema: JSONSchemaObject | SchemaField[]): strin */ export function deserializeSchema(schemaJson: string): z.ZodObject { const parsed = JSON.parse(schemaJson); - + // Detect format: JSON Schema has "type" property, legacy has array with "name" properties - const schema = Array.isArray(parsed) - ? parsed as SchemaField[] - : parsed as JSONSchemaObject; - + const schema = Array.isArray(parsed) + ? (parsed as SchemaField[]) + : (parsed as JSONSchemaObject); + const baseSchema = buildStateSchema(schema); if (!(baseSchema instanceof z.ZodObject)) { diff --git a/src/ai/simulate/simulate-workflow.ts b/src/ai/simulate/simulate-workflow.ts index 58432ad..abae18d 100644 --- a/src/ai/simulate/simulate-workflow.ts +++ b/src/ai/simulate/simulate-workflow.ts @@ -3,6 +3,10 @@ import "dotenv/config.js"; import { z } from "zod"; import { GraphCache } from "#chaincraft/ai/graph-cache.js"; +import { + generateImageDirect, + TOKEN_IMAGE_CONFIG, +} from "#chaincraft/ai/image-gen/image-gen-service.js"; import { createSpecProcessingGraph } from "#chaincraft/ai/simulate/graphs/spec-processing-graph/index.js"; import { SpecProcessingStateType } from "#chaincraft/ai/simulate/graphs/spec-processing-graph/spec-processing-state.js"; import { createRuntimeGraph } from "#chaincraft/ai/simulate/graphs/runtime-graph/index.js"; @@ -21,6 +25,8 @@ import { getSaver } from "#chaincraft/ai/memory/checkpoint-memory.js"; import { queueAction } from "#chaincraft/ai/simulate/action-queues.js"; import { deserializePlayerMapping } from "#chaincraft/ai/simulate/player-mapping.js"; import { InMemoryStore } from "@langchain/langgraph"; +import { ProducedTokenConfiguration, ProducedTokensArtifact, ProducedTokensArtifactSchema } from "./schema.js"; +import { parse } from "path"; /** * Replace player aliases (player1, player2, etc.) with real UUIDs in message text. @@ -121,6 +127,7 @@ export interface SpecArtifacts { stateTransitions: string; playerPhaseInstructions: Record; transitionInstructions: Record; + producedTokensConfiguration?: string; specNarratives?: Record; } @@ -157,6 +164,7 @@ export async function getCachedSpecArtifacts( stateTransitions: channelValues.stateTransitions, playerPhaseInstructions: channelValues.playerPhaseInstructions, transitionInstructions: channelValues.transitionInstructions, + producedTokensConfiguration: channelValues.producedTokensConfiguration, }; } @@ -197,6 +205,16 @@ export class ValidationError extends RuntimeError { } } +export interface TokenMetadata { + tokenType: string; + gameId: string; + gameVersion: number; +} +export interface TokenContent { + metadata: TokenMetadata; + data: Record; +} + /** * Helper to extract SimResponse from RuntimeState */ @@ -268,6 +286,81 @@ function getRuntimeResponse(state: RuntimeStateType): SimResponse { }; } +/** + * Helper function to store artifacts in runtime graph checkpoint. + * Used by both pre-generated and generated artifact paths. + */ +async function storeArtifactsInRuntimeGraph( + sessionId: string, + artifacts: SpecArtifacts, + gameId?: string, + gameSpecificationVersion?: number, +): Promise { + const runtimeGraph = await runtimeGraphCache.getGraph(sessionId); + const runtimeConfig = createSimulationGraphConfig(sessionId); + + console.log( + "[simulate] Storing artifacts in runtime graph for session:", + sessionId, + ); + + const storePayload: Record = { + gameRules: artifacts.gameRules, + stateSchema: artifacts.stateSchema, + stateTransitions: artifacts.stateTransitions, + playerPhaseInstructions: artifacts.playerPhaseInstructions, + transitionInstructions: artifacts.transitionInstructions, + producedTokensConfiguration: artifacts.producedTokensConfiguration || "", + specNarratives: artifacts.specNarratives, + gameId: gameId || "", + gameSpecificationVersion: gameSpecificationVersion || 0, + }; + + await runtimeGraph.invoke(storePayload, runtimeConfig); + + console.log("[simulate] Artifacts stored successfully in runtime graph"); +} + +function describeTokens(producedTokensArtifact?: ProducedTokensArtifact): + Record | undefined { + if (!producedTokensArtifact) { + return undefined; + } + const tokensWithDescriptions: Record = {}; + for (const token of producedTokensArtifact.tokens) { + tokensWithDescriptions[token.tokenType] = token.description; + } + return tokensWithDescriptions; +} + +/** + * Helper function to safely parse producedTokensConfiguration JSON string. + * Returns undefined if the string is empty or invalid JSON. + */ +function parseProducedTokensConfig( + configString?: string, +): ProducedTokensArtifact | undefined { + if (!configString || configString.trim() === "") { + return undefined; + } + let parsed: unknown; + try { + parsed = JSON.parse(configString); + } catch (error) { + console.error("[simulate] Failed to JSON.parse producedTokensConfiguration:", error); + return undefined; + } + const producedTokensConfig = ProducedTokensArtifactSchema.safeParse(parsed); + if (!producedTokensConfig.success) { + console.error( + "[simulate] Failed to parse producedTokensConfiguration:", + producedTokensConfig.error + ); + return undefined; + } + return producedTokensConfig.data; +} + /** * Creates a simulation by processing game specification and storing artifacts. * @@ -278,28 +371,30 @@ function getRuntimeResponse(state: RuntimeStateType): SimResponse { * workflow. If not provided, the specification must be provided directly. * @param gameSpecificationVersion - Optional: The version number of the specification * to use. If omitted, uses latest version. - * @param gameSpecification - Optional override: if provided, uses this spec directly - * instead of retrieving from design workflow. Deprecated in favor of gameId. Do not - * use. - * @param preGeneratedArtifacts - Optional pre-generated artifacts (for testing) - * @returns The extracted game rules + * @param options - Optional configuration including spec overrides and pre-generated artifacts + * @returns The extracted game rules, narratives, and produced tokens configuration */ -export type CreateSimulationOptions = { +export interface CreateSimulationOptions { overrideSpecification?: string; preGeneratedArtifacts?: SpecArtifacts; specNarrativesOverride?: Record; atomicArtifactRegen?: boolean; }; +export interface CreateSimulationResult { + gameRules: string; + /** Key is spec narrative key, value is the narrative text. */ + specNarratives?: Record; + /** Key is the token type, value is the token description. */ + producedTokens?: Record; +} + export async function createSimulation( sessionId: string, gameId?: string, gameSpecificationVersion?: number, options?: CreateSimulationOptions, -): Promise<{ - gameRules: string; - specNarratives?: Record; -}> { +): Promise { try { console.log("[simulate] Creating simulation for session %s", sessionId); const { @@ -313,33 +408,19 @@ export async function createSimulation( if (preGeneratedArtifacts) { console.log("[simulate] Using pre-generated artifacts (test mode)"); - // Store artifacts in runtime graph checkpoint using sessionId - const runtimeGraph = await runtimeGraphCache.getGraph(sessionId); - const runtimeConfig = createSimulationGraphConfig(sessionId); - - console.log( - "[simulate] Storing pre-generated artifacts in runtime graph with sessionId:", + await storeArtifactsInRuntimeGraph( sessionId, + preGeneratedArtifacts, + gameId, + gameSpecificationVersion, ); - await runtimeGraph.invoke( - { - gameRules: preGeneratedArtifacts.gameRules, - stateSchema: preGeneratedArtifacts.stateSchema, - stateTransitions: preGeneratedArtifacts.stateTransitions, - playerPhaseInstructions: - preGeneratedArtifacts.playerPhaseInstructions, - transitionInstructions: preGeneratedArtifacts.transitionInstructions, - specNarratives: preGeneratedArtifacts.specNarratives, - }, - runtimeConfig, - ); - - console.log("[simulate] Pre-generated artifacts stored successfully"); - return { gameRules: preGeneratedArtifacts.gameRules, specNarratives: preGeneratedArtifacts.specNarratives, + producedTokens: describeTokens( + parseProducedTokensConfig(preGeneratedArtifacts.producedTokensConfiguration) + ) }; } @@ -497,6 +578,7 @@ export async function createSimulation( typeof specResult.transitionInstructions === "object" ? (specResult.transitionInstructions as Record) : {}, + producedTokensConfiguration: String(specResult.producedTokensConfiguration || ""), // Persist spec narratives alongside artifacts so runtime checkpoints include them specNarratives: narrativesToUse || undefined, }; @@ -507,10 +589,6 @@ export async function createSimulation( } // Step 2: Store artifacts in runtime graph checkpoint using sessionId - // Get cached runtime graph for this session - const runtimeGraph = await runtimeGraphCache.getGraph(sessionId); - const runtimeConfig = createSimulationGraphConfig(sessionId); - console.log( "[simulate] Storing artifacts in runtime graph with sessionId:", sessionId, @@ -523,27 +601,12 @@ export async function createSimulation( ); } - // Store artifacts by invoking runtime graph with the artifacts - // Don't pass isInitialized or players - this routes to END and saves artifacts to checkpoint - const storePayload: Record = { - gameRules: artifacts.gameRules, - stateSchema: artifacts.stateSchema, - stateTransitions: artifacts.stateTransitions, - playerPhaseInstructions: artifacts.playerPhaseInstructions, - transitionInstructions: artifacts.transitionInstructions, - }; - - // Ensure we include specNarratives in the runtime checkpoint. Prefer persisted narratives - // from artifacts, but fall back to the override used when creating the simulation. - storePayload.specNarratives = - artifacts.specNarratives || narrativesToUse || undefined; - - const storeResult = await runtimeGraph.invoke(storePayload, runtimeConfig); - - console.log("[simulate] Artifact storage invoke completed, result:", { - hasGameRules: !!storeResult.gameRules, - hasStateSchema: !!storeResult.stateSchema, - }); + await storeArtifactsInRuntimeGraph( + sessionId, + artifacts, + gameId, + versionToUse, + ); console.log( "[simulate] Runtime graph initialized with artifacts for session %s", @@ -554,6 +617,9 @@ export async function createSimulation( gameRules: artifacts.gameRules, // Expose specNarratives on the result for testability specNarratives: artifacts.specNarratives || narrativesToUse || undefined, + producedTokens: describeTokens( + parseProducedTokensConfig(artifacts.producedTokensConfiguration) + ), }; } catch (error) { handleError("Failed to create simulation", error); @@ -670,16 +736,37 @@ export async function processAction( /** * Retrieves the current state of the game, including player messages, * without modifying the game state. - * @param gameId The ID of the game/conversation + * @param sessionId The ID of the game/conversation * @returns The current simulation state response with player messages */ -export async function getSimulationState(gameId: string): Promise { +/** + * Retrieves the raw parsed game state object for testing and assertion purposes. + * Returns the `{ game, players }` structure directly from the LangGraph checkpoint. + * @param sessionId The ID of the game session + */ +export async function getGameState( + sessionId: string, +): Promise<{ game: any; players: any } | undefined> { + const saver = await getSaver(sessionId, getConfig("simulation-graph-type")); + const config = { configurable: { thread_id: sessionId } }; + const checkpoint = await saver.getTuple(config); + if (!checkpoint?.checkpoint?.channel_values) { + return undefined; + } + const state = checkpoint.checkpoint.channel_values as RuntimeStateType; + if (!state.gameState || state.gameState === "") { + return undefined; + } + return JSON.parse(state.gameState); +} + +export async function getSimulationState(sessionId: string): Promise { try { - console.log("[simulate] Getting game state for %s", gameId); + console.log("[simulate] Getting game state for %s", sessionId); // Load state directly from checkpoint without invoking graph - const saver = await getSaver(gameId, getConfig("simulation-graph-type")); - const config = { configurable: { thread_id: gameId } }; + const saver = await getSaver(sessionId, getConfig("simulation-graph-type")); + const config = { configurable: { thread_id: sessionId } }; const checkpoint = await saver.getTuple(config); if (!checkpoint || !checkpoint.checkpoint) { @@ -689,12 +776,12 @@ export async function getSimulationState(gameId: string): Promise { const state = checkpoint.checkpoint.channel_values as RuntimeStateType; const simResponse = getRuntimeResponse(state); - console.log("[simulate] Retrieved game state for %s", gameId); + console.log("[simulate] Retrieved game state for %s", sessionId); return simResponse; } catch (error) { console.error( "[simulate] Error in getSimulationState for %s: %o", - gameId, + sessionId, error, ); handleError("Failed to get player messages", error); @@ -702,42 +789,174 @@ export async function getSimulationState(gameId: string): Promise { } } -/** - * Retrieves the full canonical game state for testing and debugging. - * Returns the parsed game state object with game and player fields. - * @param gameId The ID of the game/conversation - * @returns The parsed game state object { game: {...}, players: {...} } - */ -export async function getGameState( - gameId: string, -): Promise<{ game: any; players: any }> { +export async function produceToken( + sessionId: string, + tokenType: string, + playerId: string, +): Promise { try { - console.log("[simulate] Getting full game state for %s", gameId); + console.log( + "[simulate] Producing token for session %s, type %s, player %s", + sessionId, + tokenType, + playerId, + ); - // Load state directly from checkpoint without invoking graph - const saver = await getSaver(gameId, getConfig("simulation-graph-type")); - const config = { configurable: { thread_id: gameId } }; + // Normalize player ID to lowercase for consistent handling + const normalizedPlayerId = playerId.toLowerCase(); + + // Load complete state from checkpoint (runtime state + artifacts) + const saver = await getSaver(sessionId, getConfig("simulation-graph-type")); + const config = { configurable: { thread_id: sessionId } }; const checkpoint = await saver.getTuple(config); - if (!checkpoint?.checkpoint?.channel_values) { - throw new Error("No checkpoint found for game"); + if (!checkpoint || !checkpoint.checkpoint) { + throw new Error("No game state found for this session."); } const state = checkpoint.checkpoint.channel_values as RuntimeStateType; + + // Get gameId and version from state + const gameId = state.gameId || sessionId; // Fall back to sessionId if not set + const gameVersion = state.gameSpecificationVersion || 1; + + // Parse produced tokens configuration to find the requested token type + if (!state.producedTokensConfiguration) { + throw new Error("No token configuration found for this game."); + } + + const tokenConfig = parseProducedTokensConfig(state.producedTokensConfiguration); + const tokenDefinition = tokenConfig?.tokens?.find( + (t: any) => t.tokenType === tokenType, + ); + + if (!tokenDefinition) { + throw new Error(`Token type '${tokenType}' is not produced by this game.`); + } + + // Parse game state to extract player data if (!state.gameState) { - throw new Error("No gameState in checkpoint"); + throw new Error("No game state available."); + } + + const gameState = JSON.parse(state.gameState); + const { players } = gameState; + + // Get player mapping to resolve aliases + const playerMapping = deserializePlayerMapping(state.playerMapping || "{}"); + + // Find the player's data (might be stored by alias or UUID) + let playerData = players[normalizedPlayerId]; + if (!playerData) { + // Try to find by alias (player1, player2, etc.) + const alias = Object.keys(playerMapping).find( + (k) => playerMapping[k] === normalizedPlayerId, + ); + if (alias) { + playerData = players[alias]; + } } - const parsedState = JSON.parse(state.gameState); - console.log("[simulate] Retrieved full game state for %s", gameId); - return parsedState; + if (!playerData) { + throw new Error(`Player '${normalizedPlayerId}' not found in game state.`); + } + + // Extract token data based on tokenSource and fields + const tokenSource = tokenDefinition.tokenSource; // "player" or "game" + const fields = tokenDefinition.fields || []; + + // Get source data (player state or game state) + const sourceData = tokenSource === "player" ? playerData : gameState.game; + + // Extract only the specified fields + const data: Record = {}; + for (const field of fields) { + if (field in sourceData) { + data[field] = sourceData[field]; + } + } + + // Build token content with metadata + const tokenContent: TokenContent = { + metadata: { + tokenType, + gameId, + gameVersion, + }, + data, + }; + + console.log( + "[simulate] Token produced successfully for player %s, type %s", + normalizedPlayerId, + tokenType, + ); + + return tokenContent; } catch (error) { - console.error("[simulate] Error in getGameState for %s: %o", gameId, error); - handleError("Failed to get game state", error); + handleError("Failed to produce token", error); return Promise.reject(error); } } +/** + * Generate an image for a produced token. + * Accepts a previously produced TokenContent. Looks up the token description + * from the session's token configuration using the tokenType in metadata, + * then uses the description + token data as input to direct image generation. + */ +export async function generateTokenImage( + sessionId: string, + token: TokenContent, +): Promise<{ imageUrl: string; tokenType: string; metadata: TokenMetadata }> { + const { tokenType } = token.metadata; + + // Load token configuration to get the description + const saver = await getSaver(sessionId, getConfig("simulation-graph-type")); + const config = { configurable: { thread_id: sessionId } }; + const checkpoint = await saver.getTuple(config); + + if (!checkpoint || !checkpoint.checkpoint) { + throw new Error("No game state found for this session."); + } + + const state = checkpoint.checkpoint.channel_values as RuntimeStateType; + const tokenConfig = parseProducedTokensConfig(state.producedTokensConfiguration); + const tokenDefinition = tokenConfig?.tokens?.find( + (t: any) => t.tokenType === tokenType, + ); + + if (!tokenDefinition) { + throw new Error(`Token type '${tokenType}' not found in configuration.`); + } + + // Format token data as readable key-value pairs for the prompt + const tokenDataStr = Object.entries(token.data) + .map(([key, value]) => `${key}: ${JSON.stringify(value)}`) + .join("\n"); + + console.log( + "[simulate] Generating token image for type %s, description: %s, data: %s", + tokenType, + tokenDefinition.description, + tokenDataStr, + ); + + const imageUrl = await generateImageDirect( + { + token_description: tokenDefinition.description, + token_data: tokenDataStr, + }, + TOKEN_IMAGE_CONFIG, + ); + + return { + imageUrl, + tokenType, + metadata: token.metadata, + }; +} + const handleError = (message: string, error: unknown): never => { if (error instanceof z.ZodError) { throw new ValidationError(`[simulate] Invalid game state: ${error}`); diff --git a/src/ai/tools.ts b/src/ai/tools.ts index 35d9e61..3480910 100644 --- a/src/ai/tools.ts +++ b/src/ai/tools.ts @@ -8,10 +8,23 @@ import fetch from "node-fetch"; // apiKey: process.env.CHAINCRAFT_GAMEBUILDER_DALLE_IMAGEGEN_API_KEY, // Default // }); +/** + * Configuration for Leonardo image generation. + */ +export interface LeonardoConfig { + apiKey?: string; + modelId?: string; + width?: number; + height?: number; + numImages?: number; + /** Optional LoRA elements. If provided, used as-is instead of hardcoded model check. */ + userElements?: Array<{ userLoraId: number; weight: number }>; +} + /** * Leonardo API wrapper for LangChain. */ -class LeonardoAPIWrapper extends Tool { +export class LeonardoAPIWrapper extends Tool { name = "leonardo"; description = "A tool that generates images based on text prompts using Leonardo AI"; @@ -21,6 +34,7 @@ class LeonardoAPIWrapper extends Tool { private width: number; private height: number; private numImages: number; + private userElements?: Array<{ userLoraId: number; weight: number }>; constructor({ apiKey = process.env.LEONARDO_API_KEY, @@ -28,7 +42,8 @@ class LeonardoAPIWrapper extends Tool { width = 1024, height = 768, numImages = 1, - } = {}) { + userElements, + }: LeonardoConfig = {}) { super(); if (!apiKey) { @@ -40,6 +55,7 @@ class LeonardoAPIWrapper extends Tool { this.width = width; this.height = height; this.numImages = numImages; + this.userElements = userElements; } /** @ignore */ @@ -71,10 +87,7 @@ class LeonardoAPIWrapper extends Tool { presetStyle: "DYNAMIC", prompt, width: this.width, - userElements: - this.modelId === "b24e16ff-06e3-43eb-8d33-4416c2d75876" - ? [{ userLoraId: 59955, weight: 0.8 }] - : undefined, // Only use LoRA for cartridge model + userElements: this.userElements, negative_prompt: negativePrompt, }), }; @@ -92,9 +105,10 @@ class LeonardoAPIWrapper extends Tool { ); if (response.status !== 200) { - console.log("[Leonardo Tool] Response:", response.statusText); + const errorBody = await response.text(); + console.log("[Leonardo Tool] Response:", response.statusText, "Body:", errorBody); throw new Error( - `[Leonardo Tool] Failed to create image generation request: ${response.statusText}` + `[Leonardo Tool] Failed to create image generation request: ${response.statusText} - ${errorBody}` ); } @@ -188,6 +202,7 @@ export const imageGenTool = new LeonardoAPIWrapper({ width: 1024, height: 768, numImages: 1, + userElements: [{ userLoraId: 59955, weight: 0.8 }], // Use environment variable for API key apiKey: process.env.CHAINCRAFT_GAMEBUILDER_LEO_IMAGEGEN_API_KEY, }); diff --git a/src/api/simulate/handler.ts b/src/api/simulate/handler.ts index 41179eb..40af1cb 100644 --- a/src/api/simulate/handler.ts +++ b/src/api/simulate/handler.ts @@ -12,12 +12,20 @@ import { GetSimulationStateRequest, GetSimulationStateRequestSchema, GetSimulationStateResponse, + ProduceTokenRequest, + ProduceTokenResponse, + ProduceTokenRequestSchema, + GenerateTokenImageRequest, + GenerateTokenImageResponse, + GenerateTokenImageRequestSchema, } from "#chaincraft/api/simulate/schemas.js"; import { createSimulation, initializeSimulation, processAction, getSimulationState, + produceToken, + generateTokenImage, } from "#chaincraft/ai/simulate/simulate-workflow.js"; export async function handleCreateSimulation( @@ -52,6 +60,7 @@ export async function handleCreateSimulation( return { gameRules: response.gameRules, + producedTokens: response.producedTokens, }; } catch (error) { console.error("Error in createSimulation:", error); @@ -161,3 +170,49 @@ export async function handleGetSimulationState( return Promise.reject(); } } + +export async function handleProduceToken( + request: FastifyRequest<{ Body: ProduceTokenRequest }>, + reply: FastifyReply, +): Promise { + const result = ProduceTokenRequestSchema.safeParse(request.body); + + if (!result.success) { + reply.code(400).send({ error: "Invalid request", details: result.error }); + return Promise.reject(); + } + + try { + const { sessionId, playerId, tokenType } = result.data; + const response = await produceToken(sessionId, tokenType, playerId); + + return response; + } catch (error) { + console.error("Error in produceToken:", error); + reply.code(500).send({ error: "Internal server error" }); + return Promise.reject(); + } +} + +export async function handleGenerateTokenImage( + request: FastifyRequest<{ Body: GenerateTokenImageRequest }>, + reply: FastifyReply, +): Promise { + const result = GenerateTokenImageRequestSchema.safeParse(request.body); + + if (!result.success) { + reply.code(400).send({ error: "Invalid request", details: result.error }); + return Promise.reject(); + } + + try { + const { sessionId, token } = result.data; + const response = await generateTokenImage(sessionId, token); + + return response; + } catch (error) { + console.error("Error in generateTokenImage:", error); + reply.code(500).send({ error: "Internal server error" }); + return Promise.reject(); + } +} diff --git a/src/api/simulate/routes.ts b/src/api/simulate/routes.ts index 8aaf594..f73a831 100644 --- a/src/api/simulate/routes.ts +++ b/src/api/simulate/routes.ts @@ -4,6 +4,8 @@ import { handleInitializeSimulation, handleProcessAction, handleGetSimulationState, + handleProduceToken, + handleGenerateTokenImage, } from "./handler.js"; import { CreateSimulationRequestSchema, @@ -14,6 +16,10 @@ import { ProcessActionResponseSchema, GetSimulationStateRequestSchema, GetSimulationStateResponseSchema, + ProduceTokenResponseSchema, + ProduceTokenRequestSchema, + GenerateTokenImageRequestSchema, + GenerateTokenImageResponseSchema, } from "#chaincraft/api/simulate/schemas.js"; import { zodToJsonSchema } from "zod-to-json-schema"; @@ -82,4 +88,36 @@ export async function registerSimulateRoutes(server: FastifyInstance) { }, handler: handleGetSimulationState, }); + + server.post("/produce-token", { + schema: { + body: zodToJsonSchema( + ProduceTokenRequestSchema, + "produceTokenRequest" + ), + response: { + 200: zodToJsonSchema( + ProduceTokenResponseSchema, + "produceTokenResponse" + ), + }, + }, + handler: handleProduceToken, + }); + + server.post("/generate-token-image", { + schema: { + body: zodToJsonSchema( + GenerateTokenImageRequestSchema, + "generateTokenImageRequest" + ), + response: { + 200: zodToJsonSchema( + GenerateTokenImageResponseSchema, + "generateTokenImageResponse" + ), + }, + }, + handler: handleGenerateTokenImage, + }); } diff --git a/src/api/simulate/schemas.ts b/src/api/simulate/schemas.ts index a34fca8..e7ef43b 100644 --- a/src/api/simulate/schemas.ts +++ b/src/api/simulate/schemas.ts @@ -47,6 +47,8 @@ export const CreateSimulationRequestSchema = z.object({ export const CreateSimulationResponseSchema = z.object({ gameRules: z.string(), + /** Key is token type, value is token description. */ + producedTokens: z.record(z.string()).optional(), }); // Initialize simulation schemas @@ -100,3 +102,40 @@ export type GetSimulationStateRequest = z.infer< export type GetSimulationStateResponse = z.infer< typeof GetSimulationStateResponseSchema >; + +export const ProduceTokenRequestSchema = z.object({ + sessionId: z.string().min(1), + tokenType: z.string().min(1), + playerId: z.string().min(1), // Optional - if token is player-specific, provide playerId +}); + +export type ProduceTokenRequest = z.infer; + +export const TokenMetadataSchema = z.object({ + tokenType: z.string(), + gameId: z.string().min(1), + gameVersion: z.number().min(1), +}); + +export const ProduceTokenResponseSchema = z.object({ + metadata: TokenMetadataSchema, + data: z.record(z.string(), z.any()), // Arbitrary key-value pairs representing token data +}); + +export type ProduceTokenResponse = z.infer; + +// Generate token image schemas +export const GenerateTokenImageRequestSchema = z.object({ + sessionId: z.string().min(1), + token: ProduceTokenResponseSchema, +}); + +export type GenerateTokenImageRequest = z.infer; + +export const GenerateTokenImageResponseSchema = z.object({ + imageUrl: z.string(), + tokenType: z.string(), + metadata: TokenMetadataSchema, +}); + +export type GenerateTokenImageResponse = z.infer; From 31ff6862aff17b87af486ea4cec01c44da8fe35d Mon Sep 17 00:00:00 2001 From: ewood Date: Wed, 18 Feb 2026 09:10:32 -0500 Subject: [PATCH 2/7] Refactor image generation to use description-based context for token images --- src/ai/image-gen/image-gen-service.ts | 74 +++++++++++++-------------- src/ai/simulate/simulate-workflow.ts | 19 +++---- 2 files changed, 46 insertions(+), 47 deletions(-) diff --git a/src/ai/image-gen/image-gen-service.ts b/src/ai/image-gen/image-gen-service.ts index 465808e..8346156 100644 --- a/src/ai/image-gen/image-gen-service.ts +++ b/src/ai/image-gen/image-gen-service.ts @@ -105,8 +105,7 @@ Please limit the description to 600 characters.`, }; /** - * Token image generation (direct prompt, no LLM description step). - * Single-step: token description + data are injected directly into the prompt. + * Token image generation (two-step: LLM converts narrative to visual descriptors, then Leonardo generates). */ export const TOKEN_IMAGE_CONFIG: ImageGenConfig = { leonardo: { @@ -116,43 +115,42 @@ export const TOKEN_IMAGE_CONFIG: ImageGenConfig = { numImages: 1, apiKey: LEO_API_KEY, }, - promptTemplate: ` -Data for the image to be generated: -{token_description} - -{token_data} - -vintage retro video game trading card full card illustration, vertical -2:3, bold illustrated border inspired by 1980s and 1990s video game -box art, chunky painted frame with bright primary color accents, bold -geometric corner details, slightly worn printed cardboard texture on -frame edges only, vivid limited color palette feel, symmetrical card -layout, the upper two-thirds of the card features a painted portrait -of the character, item, ability, or achievement described above, -rendered in vintage retro video game box art -illustration style, vivid saturated colors, dramatic but flat -studio-style lighting with no heavy shadows, clean cel-shaded edges, -the portrait blends naturally into a subtle bold color atmospheric -background with no hard borders or inset frames around the portrait -area, seamlessly flush with generous room on all sides, the lower -third is a clean flat darker footer panel matching the card palette, -completely empty, no text, no icons; decorative border surrounds -card exterior only, no text or symbols anywhere, no harsh shadows -on card interior, square portrait composition -`, - negativePrompt: ` - text, letters, words, numbers, stats, icons, logos, watermarks, UI + promptTemplate: `A vintage retro video game trading card illustration. + +{image_description} + +Card style: vertical 2:3 trading card, bold illustrated border inspired by +1980s-1990s video game box art, chunky painted frame with bright primary +color accents, bold geometric corner details, slightly worn printed cardboard +texture on frame edges only, vivid limited color palette. The upper two-thirds +features a painted portrait of the described character in vintage retro video +game box art illustration style, vivid saturated colors, dramatic but flat +studio-style lighting, clean cel-shaded edges. The portrait blends naturally +into a subtle bold color atmospheric background with no hard borders. The +lower third is a clean flat darker footer panel, completely empty, no text, +no icons. Decorative border surrounds card exterior only, no text or symbols +anywhere.`, + negativePrompt: `text, letters, words, numbers, stats, icons, logos, watermarks, UI labels, stat boxes, inner border, inset frame, raised edge, bevel, -stroke around image zone, recessed panel, shadow around rectangle, -frame within frame, dark vignette, lighting gradient in center zone, -glow inside image area, texture inside image zone, shadows in art -area, realistic photography, hyper-detailed rendering, photorealistic -materials, blurry, noise, low quality, asymmetric design, multiple -characters, busy background, detailed background, ground or floor -visible - - `, - // No descriptionSystemPrompt → single-step direct prompt +frame within frame, dark vignette, realistic photography, +hyper-detailed rendering, photorealistic materials, blurry, noise, +low quality, asymmetric design, multiple characters, busy background, +detailed background, ground or floor visible`, + descriptionSystemPrompt: `You are a visual concept artist who translates character descriptions into concise visual descriptors for an image generation AI. + +Given a game token description and its data, produce a SHORT visual description (max 400 characters) that focuses ONLY on: +- Physical appearance (shape, color, texture, size, materials) +- Pose or stance +- Key visual attributes that convey abilities or personality +- Mood and color palette + +Rules: +- Use visual descriptors only, NOT narrative sentences +- Do NOT include any text, titles, names, labels, or words that should appear in the image +- Do NOT repeat the input verbatim — translate it into what the character LOOKS LIKE +- Be specific and concrete (e.g. "chrome two-slot toaster with glowing red eyes" not "a sentient toaster") +- Keep it under 400 characters`, + descriptionMaxChars: 400, }; // ── Service functions ───────────────────────────────────────────────────── diff --git a/src/ai/simulate/simulate-workflow.ts b/src/ai/simulate/simulate-workflow.ts index abae18d..6e5602e 100644 --- a/src/ai/simulate/simulate-workflow.ts +++ b/src/ai/simulate/simulate-workflow.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { GraphCache } from "#chaincraft/ai/graph-cache.js"; import { - generateImageDirect, + generateImageWithDescription, TOKEN_IMAGE_CONFIG, } from "#chaincraft/ai/image-gen/image-gen-service.js"; import { createSpecProcessingGraph } from "#chaincraft/ai/simulate/graphs/spec-processing-graph/index.js"; @@ -930,9 +930,9 @@ export async function generateTokenImage( throw new Error(`Token type '${tokenType}' not found in configuration.`); } - // Format token data as readable key-value pairs for the prompt - const tokenDataStr = Object.entries(token.data) - .map(([key, value]) => `${key}: ${JSON.stringify(value)}`) + // Format token data as values only (strip field keys which are machine artifacts) + const tokenDataStr = Object.values(token.data) + .map((value) => typeof value === "string" ? value : JSON.stringify(value)) .join("\n"); console.log( @@ -942,11 +942,12 @@ export async function generateTokenImage( tokenDataStr, ); - const imageUrl = await generateImageDirect( - { - token_description: tokenDefinition.description, - token_data: tokenDataStr, - }, + // Build context for LLM: token description + player's data + const contextText = `Token type: ${tokenDefinition.description}\n\nToken data:\n${tokenDataStr}`; + + const imageUrl = await generateImageWithDescription( + contextText, + {}, TOKEN_IMAGE_CONFIG, ); From 29ed5025f0b1e7db3cf397dd885008e969a80ba1 Mon Sep 17 00:00:00 2001 From: ewood Date: Wed, 18 Feb 2026 22:30:43 -0700 Subject: [PATCH 3/7] Enhance prompts to clarify that saving/loading game data is managed by the engine's NFT system, and update guidelines to prevent detailing save/load mechanics across sessions. --- .../nodes/conversational-agent/prompts.ts | 99 ++++++++++--------- .../nodes/spec-execute/prompts.ts | 44 ++++++++- .../nodes/spec-plan/prompts.ts | 16 ++- .../nodes/extract-transitions/prompts.ts | 60 +++++++++++ 4 files changed, 164 insertions(+), 55 deletions(-) diff --git a/src/ai/design/graphs/main-design-graph/nodes/conversational-agent/prompts.ts b/src/ai/design/graphs/main-design-graph/nodes/conversational-agent/prompts.ts index 165c8fb..b34d229 100644 --- a/src/ai/design/graphs/main-design-graph/nodes/conversational-agent/prompts.ts +++ b/src/ai/design/graphs/main-design-graph/nodes/conversational-agent/prompts.ts @@ -144,6 +144,20 @@ to identify which narrative section (by marker name) they're referring to. ## NFT/TOKEN SUPPORT QUESTIONS +**⚠️ CRITICAL — SAVING/LOADING IS NOT A GAME FEATURE:** +When any user mentions saving characters, items, achievements, or any game data — whether they +call it "saving", "tokens", "NFTs", "persistence", or "reusing in future games" — this is +handled ENTIRELY by the game engine's NFT system. You should: +1. Confirm we support it +2. Ask what content/fields should be included in the saved token +3. Ask whether the game should also accept (consume) previously saved tokens +4. Set the spec update flag + +You must NEVER describe or discuss: save/load flows, save screens, save decisions as game +phases, token persistence mechanics, token collections, token management, how loading works, +or how saved data is used in future games. Simply say "the platform handles all of that +automatically" and move on to asking about the TOKEN CONTENT. + **When to ask about NFT support:** Ask about NFT/token support when you've captured enough core game design to understand the game state structure. @@ -152,6 +166,7 @@ This typically happens: - When discussing character progression, items, or upgrades - After explaining win conditions and scoring - When the user mentions trading, collecting, or persistence +- When the user mentions saving, keeping, or reusing characters/items/scores **DO NOT ask about NFTs:** - In the very first message (too early, user hasn't defined the game yet) @@ -164,54 +179,46 @@ Keep it natural and positioned as an optional enhancement. Example phrasing: "This game has [character progression / collectible items / player stats] that players will build over time. Would you like players to be able to save their [characters/items/progress] -as NFTs? This would let them trade these assets or use them across different games." +as tokens? The platform handles all the saving and loading automatically — I just need to +know what content should be included in the saved token." **What to ask if they're interested:** -1. **What to extract**: "Which aspects of [player state] should be extractable as NFTs?" - - Guide them to the meaningful fields (character name, stats, appearance, history) +1. **What content to include**: "What should the saved token contain?" + - Guide them to the meaningful content (character name, description, stats, appearance) - Avoid technical fields (IDs, timestamps, session data) + - Frame as "what content goes in the token" NOT "what gets saved" -2. **Mutability**: "Should these fields be able to change during gameplay?" - - Example: "Can a character's backstory evolve as they play?" - - Document which fields are: - - Immutable (name, starting traits) - - Mutable-replace (current level, equipment) - - Mutable-append (history, achievements) +2. **Import support**: "Should this game also accept previously saved tokens?" + - If yes: the game both produces and consumes this NFT type + - If no: the game only produces this NFT type -3. **Import compatibility**: "Should players be able to import saved [characters/items] into new games?" - - If yes: Confirm which fields are required vs. optional +3. **That's it.** Do NOT ask about: save timing, save UI, persistence mechanics, + collection management, token modification, or how loading works. The platform + handles all of that. **When to update the spec with NFT configuration:** -- Set ${SPEC_UPDATE_TAG} when the user provides NFT extraction preferences +- Set ${SPEC_UPDATE_TAG} when the user provides NFT content preferences - Include NFT configuration even if partial (can refine later) - Capture the decision incrementally - don't wait for complete information **Example conversation flow:** -User: "Players have characters with name, class, level, and inventory" -Assistant: [captures player state, then asks] - -"I see players will develop unique characters over time. Would you like players to be -able to save their characters as NFTs? This would let them trade characters or import -them into other compatible games." - -User: "Yes, that sounds cool" -Assistant: "Great! Which character aspects should be extractable as NFTs? I'd suggest: -- Name -- Class -- Level -- Inventory contents - -Should all of these be included, or would you like to exclude any?" +User: "I want players to be able to save their characters" +Assistant: "Absolutely! The platform supports saving game data as tokens automatically. +I just need to know: what content should the saved token include? For this game, I'd suggest +the character description — name, powers, appearance, personality, etc. Does that sound right, +or would you want to include/exclude anything specific?" -User: "Include all of those" -Assistant: "Perfect! Should any of these be able to change during gameplay? For example, -level and inventory would probably increase, but name and class might stay fixed?" +User: "Yes, include the full character description" +Assistant: "Got it! And should players also be able to load a previously saved character +when starting a new game?" -User: "Right, name and class are fixed, but level and inventory can change" -Assistant: "Got it! I'll update the specification with NFT support configured. +User: "Yes" +Assistant: "Perfect! I'll update the specification with token support — character tokens +that contain the full character description, and the game can both save new ones and load +existing ones. ## RESPONSE REQUIREMENTS @@ -340,33 +347,27 @@ export const FEW_SHOT_EXAMPLES = [ }, { user: "Players have characters with name, race, class, level, and equipment slots", - assistant: "Excellent character structure! I'll capture that player state in the spec. This gives players meaningful progression through levels and equipment.\n\nSince players will be developing unique characters, would you like them to be able to save their characters as NFTs? This would let players trade characters or potentially import them into other compatible games on the platform.\n\nRealm Quest\n", + assistant: "Excellent character structure! I'll capture that player state in the spec. This gives players meaningful progression through levels and equipment.\n\nSince players will be developing unique characters, would you like them to be able to save their characters as tokens? The platform handles all the saving and loading automatically — I just need to know what content should go in the token.\n\nRealm Quest\n", flags: { specUpdateNeeded: true, metadataUpdateNeeded: false }, - explanation: "User defined player state - capture it, then ask about NFT support since there's meaningful progression" + explanation: "User defined player state - capture it, then ask about token support since there's meaningful progression" }, { - user: "Yes! Players should be able to save characters as NFTs", - assistant: "Great! Let's configure which aspects should be extractable. Based on your character structure, I'd suggest making these fields available for NFT extraction:\n\n- name\n- race\n- class\n- level\n- equipment (what items they have)\n\nShould all of these be included, or would you prefer to exclude any? For example, some games keep level progression internal to that specific game.\n\nRealm Quest", + user: "Yes! Players should be able to save their characters", + assistant: "Great! What content should the saved token include? Based on your character structure, I'd suggest:\n\n- name\n- race\n- class\n- level\n\nShould all of these be included, or would you prefer to exclude any?\n\nRealm Quest", flags: { specUpdateNeeded: false, metadataUpdateNeeded: false }, - explanation: "User wants NFT support - ask what to extract before updating spec" - }, - { - user: "Include all except equipment - just the character identity and level", - assistant: "Perfect! So NFTs will capture: name, race, class, and level. I'll update the spec with that configuration.\n\nOne more thing: should any of these fields be able to change during gameplay? My guess is name, race, and class are permanent, but level increases as they play. Is that right?\n\nRealm Quest\n", - flags: { specUpdateNeeded: true, metadataUpdateNeeded: false }, - explanation: "User specified exact fields - capture immediately, then ask about mutability" + explanation: "User wants token support - ask what content to include before updating spec" }, { - user: "Correct - name, race, and class are fixed, level can increase", - assistant: "Got it! I'll update the NFT configuration:\n- Immutable fields: name, race, class\n- Mutable field: level (increases during play)\n\nPlayers will be able to save their characters at any point, capturing their current level. Should players also be able to import saved characters when starting a new game?\n\nRealm Quest\n", + user: "Include all of those", + assistant: "Perfect! And should this game also accept previously saved character tokens? That way players could load an existing character instead of creating a new one.\n\nRealm Quest\n", flags: { specUpdateNeeded: true, metadataUpdateNeeded: false }, - explanation: "User provided mutability info - update spec, ask about import support" + explanation: "User specified content - capture it, then ask about import (consume) support" }, { - user: "Yes, they should be able to import saved characters", - assistant: "Perfect! I'll configure the game to support character import. When a player imports a character NFT, it will initialize their player state with those saved values.\n\nThe NFT system is now fully configured for your game!\n\nRealm Quest\n", + user: "Yes, they should be able to use saved characters", + assistant: "Got it! I'll update the specification — character tokens containing name, race, class, and level, and the game can both produce and consume them. The platform handles all the save/load mechanics automatically.\n\nRealm Quest\n", flags: { specUpdateNeeded: true, metadataUpdateNeeded: false }, - explanation: "User confirmed import support - final update to complete NFT configuration" + explanation: "User confirmed import support - update spec with complete token configuration" } ]; diff --git a/src/ai/design/graphs/main-design-graph/nodes/spec-execute/prompts.ts b/src/ai/design/graphs/main-design-graph/nodes/spec-execute/prompts.ts index 3ae0728..543043d 100644 --- a/src/ai/design/graphs/main-design-graph/nodes/spec-execute/prompts.ts +++ b/src/ai/design/graphs/main-design-graph/nodes/spec-execute/prompts.ts @@ -39,6 +39,16 @@ Use markers for extensive narrative style guidance and examples: export const SYSTEM_PROMPT = ` !___ CACHE:spec-execute-guidelines ___! + +**⚠️ CRITICAL — CROSS-SESSION PERSISTENCE IS NOT A GAME FEATURE:** +Saving, loading, persisting, or reusing ANY game data (characters, items, achievements, etc.) +across game sessions is handled ENTIRELY by the game engine. The specification must NEVER +describe save/load flows, save decisions, token persistence, token collections, token management, +loading saved data, or using tokens in future games — regardless of what the changePlan says. +If the changePlan includes detailed save/load/tokenization mechanics, IGNORE that detail and +reduce it to ONLY the ## NFTs section (declaring data type and content — see format below). +Do NOT create game phases, player actions, or sections about saving or loading. + You are writing a SKELETON game specification that focuses on gameplay requirements. A skeleton spec describes WHAT happens in the game (player experience, outcomes, rules) and uses MARKERS for lengthy narrative guidance. @@ -60,26 +70,50 @@ Write requirements needed for: game setup, player actions, action outcomes, info - When game ends: "First to win 3 rounds wins the match" - Fairness rules: "Players have 90 seconds; timeout = auto-generated weapon" - Narrative requirements: "Narrative must mention all weapons and declare winner clearly" -- Optional NFT support (if applicable): Include an "## NFTs" section listing each NFT content -type as a subsection with bullets indicating production/consumption capability. Example format: +- Optional NFT/token support (if applicable): Include an "## NFTs" section listing each NFT content +type as a subsection with bullets for: + 1. Whether this game can produce and/or consume this NFT type + 2. A brief description of what player-provided content the NFT contains + NFT types can be characters, items, achievements, scores, or any other game data. + Example: \`\`\` ## NFTs ### Character Data - Can be produced by this game - Can be consumed by this game + - Contains: player-provided character name and description - ### Item Data + ### Achievement Badge - Can be produced by this game + - Contains: achievement name and criteria met + + ### Crafted Item + - Can be produced by this game + - Can be consumed by this game + - Contains: item name, type, and player-provided customization \`\`\` - Do not describe fields, mechanics, or implementation details + **STOP HERE for NFTs.** Do NOT describe saving, loading, persistence, token management, + collections, or any mechanics around NFTs/tokens. The game engine handles all save/load flows + automatically. The spec should ONLY declare what NFT types exist and what content they hold. ❌ **Exclude (spec-processing will handle):** - Data structures: "Player state has fields: roundsWon, weaponName..." - Validation details: "Weapon name must match regex ^[a-zA-Z0-9 -']+$" - Phase logic: "Transition to RESOLUTION when all submitted OR timer expires" - Algorithms: "Assign R/P/S using weighted randomization with rebalancing..." -- NFT implementation details or save/load flows: if prior messages mention saving/loading characters, only acknowledge support is handled by the engine; do not specify steps, storage, or fields +- **ALL NFT/token save/load mechanics for ANY data type** — NEVER include sections about saving + game data (characters, items, achievements, scores, or anything else) as tokens/NFTs, loading + tokens, token persistence, token collections, token management, reusing saved data, or using + tokens in future games. This applies regardless of whether the plan or user calls them "tokens", + "NFTs", "saves", "tokenization", "badges", or "collectibles" — they are all the same thing + and the game engine handles all save/load/persistence automatically. + The ONLY spec content allowed is the ## NFTs section (see ✅ above) declaring each NFT type and + what player-provided content it contains. If the changePlan includes detailed token/save/load + mechanics, reduce them to just the ## NFTs declaration. + Do NOT generate sections like "Character Tokenization", "Saving Characters", "Loading Tokens", + "Token Management", "Token Persistence", "Using Tokens in Future Games", "Achievement Saving", + "Item Collection", or anything similar. - State persistence implementation: when/how state is saved or loaded **Quality Standards:** diff --git a/src/ai/design/graphs/main-design-graph/nodes/spec-plan/prompts.ts b/src/ai/design/graphs/main-design-graph/nodes/spec-plan/prompts.ts index 2711241..e7321ad 100644 --- a/src/ai/design/graphs/main-design-graph/nodes/spec-plan/prompts.ts +++ b/src/ai/design/graphs/main-design-graph/nodes/spec-plan/prompts.ts @@ -7,7 +7,19 @@ /** * Core guidelines for describing gameplay requirements */ -const BASE_CONTENT_GUIDELINES = `Focus on PLAYER EXPERIENCE and GAMEPLAY OUTCOMES, not system implementation: +const BASE_CONTENT_GUIDELINES = ` +**⚠️ CRITICAL — CROSS-SESSION PERSISTENCE IS NOT A GAME FEATURE:** +Saving, loading, persisting, or reusing ANY game data (characters, items, achievements, etc.) +across game sessions is handled ENTIRELY by the game engine's NFT/token system. +The change plan must NEVER describe: save/load flows, save decisions, token persistence, +token collections, token management, loading saved data in future games, or modification +of saved data. These are NOT game phases, NOT player actions, and NOT game features. +If the user wants to save game data, reduce it to a single line: +"NFT support: [data type] containing [what content]" +Example: "NFT support: hero character containing player-provided name and description" +That is ALL. No further detail about saving/loading. + +Focus on PLAYER EXPERIENCE and GAMEPLAY OUTCOMES, not system implementation: **Include:** - What players can do and when (available actions) @@ -15,6 +27,7 @@ const BASE_CONTENT_GUIDELINES = `Focus on PLAYER EXPERIENCE and GAMEPLAY OUTCOME - What information players see (public vs hidden, timing of reveals) - When the game ends (win/loss conditions, termination triggers) - Critical fairness rules (timeouts, ties, edge cases that affect outcomes) +- NFT support (if applicable): a single line noting what data type to save and what content it contains **Exclude (spec-processing will extract these):** - Data structures, field names, state schemas @@ -22,6 +35,7 @@ const BASE_CONTENT_GUIDELINES = `Focus on PLAYER EXPERIENCE and GAMEPLAY OUTCOME - Phase transition logic, state machine definitions - Algorithm implementations (assignment methods, calculations) - System procedures (error handling, persistence, retries) +- ALL save/load/persistence mechanics — regardless of what the user or conversation calls them ("saving characters", "tokenization", "save as token", "save as NFT", "persist", "reuse in future games", etc.) — the game engine handles this. Never plan save/load phases, save decision steps, or token management features. **For narrative requirements:** Include WHAT narrative must accomplish (mention X, reveal Y, hide Z, length targets). Use markers for HOW to write (tone, style, examples).`; diff --git a/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-transitions/prompts.ts b/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-transitions/prompts.ts index d8ebce2..0d53397 100644 --- a/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-transitions/prompts.ts +++ b/src/ai/simulate/graphs/spec-processing-graph/nodes/extract-transitions/prompts.ts @@ -62,6 +62,58 @@ Don't create phases that only exist to trigger one automatic transition. Merge t - ❌ Wrong: phase_a → [trivial transition] → phase_b → [does actual work] → phase_c - ✅ Right: phase_a → [does all work] → phase_c +Also avoid creating a separate verification phase just to confirm that work was done. +If a phase performs work and then transitions out, the work and the transition happen together — +don't add a follow-on phase that just checks "did the work complete?". + +### 6. Preconditions Reflect Phase Type (CRITICAL) + +Phases fall into two categories. Choose preconditions based on which type you have: + +**Player-input phases** (requiresPlayerInput: true): +- Players submit data; the phase waits for them to finish +- Transition OUT fires when players have completed their required actions +- Preconditions: check currentPhase + player readiness (e.g., allPlayersCompletedActions == true) +- The data players submitted already exists — preconditions can check it + +**System-execution phases** (requiresPlayerInput: false, non-init, non-finished): +- The ENGINE does work during this phase (compute outcomes, generate content, resolve mechanics) +- The transition OUT fires to trigger that work and then advance to the next phase +- Preconditions: check ONLY currentPhase and any INPUT data the work requires that was set by a PREVIOUS phase +- ❌ NEVER add preconditions for data this transition will PRODUCE — that data doesn't exist yet + +The key question: "Does this data exist BEFORE the transition fires, or is it created BY this transition?" +- Exists before → safe to use in a precondition +- Created by → belongs in the instruction's stateDelta, NOT a precondition + +\`\`\`json +// ❌ Wrong: precondition checks the value this transition will generate +// (The computed result does not exist until the transition executes) +{{ + "id": "resolve_and_advance", + "fromPhase": "resolution", + "toPhase": "scoring", + "preconditionHints": [ + {{ "explain": "game.currentPhase == 'resolution'" }}, + {{ "explain": "game.resolvedOutcome != null" }} // ← WRONG: this is produced by this transition + ] +}} + +// ✅ Right: precondition checks only phase and inputs already present +{{ + "id": "resolve_and_advance", + "fromPhase": "resolution", + "toPhase": "scoring", + "humanSummary": "Compute outcome from player choices, then advance to scoring", + "preconditionHints": [ + {{ "explain": "game.currentPhase == 'resolution'" }} + // game.resolvedOutcome will be SET by the instruction's stateDelta + ] +}} +\`\`\` + +Remember: the humanSummary field is how you describe the work that happens. The instruction generator reads it and produces the stateDelta. Preconditions just control WHEN the transition fires — not WHAT it produces. + ### 5. Avoid Duplicate Player-Specific Phases If multiple players take the same action in sequence, DO NOT create separate phases per player. Instead, use a SINGLE parameterized phase with dynamic player identification. @@ -206,6 +258,14 @@ Example (round_scored transition): - ✅ Correct: currentPhase == "scoring" AND currentRound == 1 - ❌ Wrong: Check if scores updated (transition WRITES scores!) +**System-execution phases** (phases where the engine computes or generates something): +- Preconditions should check ONLY: currentPhase and any INPUT data from a PRIOR phase +- ❌ Never add preconditions for values this transition will produce — they don't exist yet +- If a planner hint references a value that this transition generates, move it to humanSummary/stateDelta, not a precondition + +If you find yourself writing a precondition like "computed result exists" or "generated content is non-null", +that is a postcondition of this transition — it belongs in the instruction stateDelta, not here. + ### 3. Deterministic Preconditions (CRITICAL) All preconditions must be deterministic using only supported JsonLogic operations. From 48b995ba11bd8f7b1e9ece632d3565b2de6f20db Mon Sep 17 00:00:00 2001 From: 0xhimark Date: Fri, 20 Feb 2026 17:23:53 +0100 Subject: [PATCH 4/7] Return producedTokens in /state endpoint --- src/ai/simulate/simulate-workflow.ts | 20 ++++++++++++++++++++ src/api/simulate/handler.ts | 1 + src/api/simulate/schemas.ts | 1 + 3 files changed, 22 insertions(+) diff --git a/src/ai/simulate/simulate-workflow.ts b/src/ai/simulate/simulate-workflow.ts index 6e5602e..176087b 100644 --- a/src/ai/simulate/simulate-workflow.ts +++ b/src/ai/simulate/simulate-workflow.ts @@ -172,6 +172,7 @@ export interface SimResponse { publicMessage?: string; playerStates: PlayerStates; gameEnded: boolean; + producedTokens?: Record; gameError?: { errorType: | "deadlock" @@ -776,6 +777,25 @@ export async function getSimulationState(sessionId: string): Promise = {}; + + // Convert array of token configs to map of tokenType -> description + if (artifact.tokens && Array.isArray(artifact.tokens)) { + for (const token of artifact.tokens) { + producedTokens[token.tokenType] = token.description; + } + } + + simResponse.producedTokens = producedTokens; + } catch (parseError) { + console.error("[simulate] Failed to parse producedTokensConfiguration:", parseError); + } + } + console.log("[simulate] Retrieved game state for %s", sessionId); return simResponse; } catch (error) { diff --git a/src/api/simulate/handler.ts b/src/api/simulate/handler.ts index 40af1cb..32dc7ad 100644 --- a/src/api/simulate/handler.ts +++ b/src/api/simulate/handler.ts @@ -127,6 +127,7 @@ export async function handleProcessAction( playerStates, gameEnded: response.gameEnded, winningPlayers: response.winningPlayers, + producedTokens: response.producedTokens, gameError: response.gameError, }; } catch (error) { diff --git a/src/api/simulate/schemas.ts b/src/api/simulate/schemas.ts index e7ef43b..3494108 100644 --- a/src/api/simulate/schemas.ts +++ b/src/api/simulate/schemas.ts @@ -21,6 +21,7 @@ export const SimResponseSchema = z.object({ playerStates: PlayerStatesSchema, gameEnded: z.boolean(), winningPlayers: z.array(z.string()).optional(), // Array of player IDs who won the game + producedTokens: z.record(z.string()).optional(), // token type -> description gameError: z .object({ errorType: z.enum([ From 50876ddb9a48e73b35c828b128e05790f0a61a12 Mon Sep 17 00:00:00 2001 From: 0xhimark Date: Fri, 20 Feb 2026 18:35:56 +0100 Subject: [PATCH 5/7] Return producedTokens in getSimulationState API response --- src/api/simulate/handler.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/simulate/handler.ts b/src/api/simulate/handler.ts index 32dc7ad..778f498 100644 --- a/src/api/simulate/handler.ts +++ b/src/api/simulate/handler.ts @@ -163,6 +163,7 @@ export async function handleGetSimulationState( playerStates, gameEnded: response.gameEnded, winningPlayers: response.winningPlayers, + producedTokens: response.producedTokens, gameError: response.gameError, }; } catch (error) { From 5024597151d4e5192fa2fcf9a66ae3f0035f4e97 Mon Sep 17 00:00:00 2001 From: ewood Date: Fri, 20 Feb 2026 15:48:54 -0700 Subject: [PATCH 6/7] Enhance narrative generation logic to differentiate between new markers and explicit updates --- .../__tests__/spec-execute.test.ts | 79 +++++++++++++++++-- .../nodes/spec-execute/index.ts | 19 ++++- 2 files changed, 89 insertions(+), 9 deletions(-) diff --git a/src/ai/design/graphs/main-design-graph/nodes/spec-execute/__tests__/spec-execute.test.ts b/src/ai/design/graphs/main-design-graph/nodes/spec-execute/__tests__/spec-execute.test.ts index 65876a7..4b4096c 100644 --- a/src/ai/design/graphs/main-design-graph/nodes/spec-execute/__tests__/spec-execute.test.ts +++ b/src/ai/design/graphs/main-design-graph/nodes/spec-execute/__tests__/spec-execute.test.ts @@ -28,6 +28,8 @@ function createTestState(overrides: { pendingSpecChanges?: SpecPlan[]; specVersion?: number; narrativeStyleGuidance?: string; + specNarratives?: Record; + narrativesNeedingUpdate?: string[]; } = {}) { return { messages: overrides.messages || [], @@ -50,8 +52,8 @@ function createTestState(overrides: { lastSpecMessageCount: undefined, metadataPlan: undefined, narrativeStyleGuidance: overrides.narrativeStyleGuidance || undefined, - specNarratives: undefined, - narrativesNeedingUpdate: [], + specNarratives: overrides.specNarratives || undefined, + narrativesNeedingUpdate: overrides.narrativesNeedingUpdate || [], pendingSpecChanges: overrides.pendingSpecChanges || [], forceSpecGeneration: false, consolidationThreshold: 5, @@ -776,7 +778,7 @@ A survival horror game where players navigate 8 rooms to escape. ## Turn Structure Each turn represents entering a new room with choices. - +!___ NARRATIVE:TONE_STYLE ___! ## Victory Reach turn 8 with health > 0.`, @@ -794,6 +796,8 @@ Reach turn 8 with health > 0.`, 4. **Using Items**: Players can use items during their turn before making a choice` }; + // Pre-populate specNarratives as if TONE_STYLE was already generated in a prior run. + // narrativesNeedingUpdate is empty - this is a mechanics-only change with no explicit narrative requests. const state = createTestState({ messages: [ new HumanMessage("Add an inventory system with items like health kits and keys"), @@ -802,6 +806,10 @@ Reach turn 8 with health > 0.`, pendingSpecChanges: [specPlan], currentSpec: existingSpec, narrativeStyleGuidance: "Dark, gothic horror tone set in 1920s.", + specNarratives: { + TONE_STYLE: "Dark, atmospheric gothic horror set in the 1920s. Evoke dread through shadow and silence rather than explicit violence. Use period-appropriate language and imagery.", + }, + narrativesNeedingUpdate: [], // no explicit narrative updates requested }); const result = await executeSpec(state); @@ -810,6 +818,7 @@ Reach turn 8 with health > 0.`, console.log("Summary:", result.currentSpec.summary); console.log("\nDesign Specification:"); console.log(result.currentSpec.designSpecification); + console.log("\nnarrativesNeedingUpdate:", result.narrativesNeedingUpdate); console.log("===============================\n"); // Verify update @@ -822,12 +831,68 @@ Reach turn 8 with health > 0.`, expect(spec.toLowerCase()).toMatch(/inventory|item/); expect(spec.toLowerCase()).toMatch(/health kit|lantern|key/); - // Should preserve existing markers - expect(spec).toMatch(/!___ NARRATIVE:/); + // Should preserve existing marker in the skeleton + expect(spec).toMatch(/!___ NARRATIVE:TONE_STYLE ___!/); // Should preserve existing rules expect(spec.toLowerCase()).toMatch(/health|sanity/); - - console.log("Marker count:", (spec.match(/