diff --git a/.env.example b/.env.example index de44713..f68e8e6 100644 --- a/.env.example +++ b/.env.example @@ -26,7 +26,6 @@ ANTHROPIC_API_KEY_PLAY=your-api-key LATEST_SONNET_MODEL=claude-sonnet-4-5-20250929 LATEST_HAIKU_MODEL=claude-haiku-4-5-20251001 -HAIKU_3_5_MODEL=claude-3-5-haiku-20241022 # This model is used for most design tasks CHAINCRAFT_DESIGN_MODEL_NAME=${LATEST_HAIKU_MODEL} @@ -40,7 +39,7 @@ CHAINCRAFT_SIM_GRAPH_CACHE_SIZE=100 # Optional configurations # Design agent model overrides (uses CHAINCRAFT_DESIGN_MODEL_NAME if not specified) # Conversation agent model - use Haiku 3.5 due to lower caching limit. -CHAINCRAFT_CONVERSATIONAL_AGENT_MODEL=${HAIKU_3_5_MODEL} +CHAINCRAFT_CONVERSATIONAL_AGENT_MODEL=${LATEST_HAIKU_MODEL} # Spec Execute Agent - Generates full specifications (requires Sonnet 4.5 for quality/capacity) CHAINCRAFT_SPEC_EXECUTE_MODEL=${LATEST_SONNET_MODEL} # Spec Narrative Agent - Generates narrative descriptions for game specs 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 5ffcfcc..bed557a 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 { @@ -35,7 +28,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", { @@ -49,18 +46,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 035b443..17f5bcc 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 @@ -155,6 +155,84 @@ 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 + +**⚠️ 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. +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 +- 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) +- 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 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 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. **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. **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 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: "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: "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: "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 1. **Always include a game title** in your response using the format: @@ -282,6 +360,30 @@ 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 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 token support since there's meaningful progression" + }, + { + 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 token support - ask what content to include before updating spec" + }, + { + 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 specified content - capture it, then ask about import (consume) support" + }, + { + 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 - update spec with complete token 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/__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(/