diff --git a/src/components/blueprint/layout/header.tsx b/src/components/blueprint/layout/header.tsx
index 6cd0156..dd268d7 100644
--- a/src/components/blueprint/layout/header.tsx
+++ b/src/components/blueprint/layout/header.tsx
@@ -1,6 +1,8 @@
import React from "react";
import {useViewportSize} from "@mantine/hooks";
-import {AppShell, Box, Burger, Button, Center, Container, CopyButton, Group, Title} from "@mantine/core";
+import {ActionIcon, AppShell, Box, Burger, Button, Center, Container, CopyButton, Group, Title, Tooltip} from "@mantine/core";
+import {IconMessageChatbot} from "@tabler/icons-react";
+import {SeedQueueControls} from "../../SeedQueue.tsx";
import {useCardStore} from "../../../modules/state/store.ts";
import SearchSeedInput from "../../searchInput.tsx";
import {GaEvent} from "../../../modules/useGA.ts";
@@ -13,6 +15,8 @@ export default function Header() {
const outputOpened = useCardStore(state => state.applicationState.asideOpen);
const toggleOutput = useCardStore(state => state.toggleOutput);
+ const assistantOpened = useCardStore(state => state.applicationState.assistantOpen);
+ const toggleAssistant = useCardStore(state => state.toggleAssistant);
return (
@@ -30,7 +34,8 @@ export default function Header() {
-{width > 600 && start && }
+ {width > 900 && }
+ {width > 600 && start && }
{width > 700 && start && (
{({ copied, copy }) => (
@@ -40,6 +45,17 @@ export default function Header() {
)}
)}
+
+
+
+
+
{
GaEvent('side_panel_toggled')
toggleOutput()
diff --git a/src/components/blueprint/layout/navbar.tsx b/src/components/blueprint/layout/navbar.tsx
index 876b419..d0c334b 100644
--- a/src/components/blueprint/layout/navbar.tsx
+++ b/src/components/blueprint/layout/navbar.tsx
@@ -27,6 +27,7 @@ import { GaEvent } from "../../../modules/useGA.ts";
import SeedInputAutoComplete from "../../SeedInputAutoComplete.tsx";
import UnlocksModal from "../../unlocksModal.tsx";
import FeaturesModal from "../../FeaturesModal.tsx";
+import { SeedQueuePanel } from "../../SeedQueue.tsx";
import { DrawSimulatorModal } from "../../DrawSimulatorModal.tsx";
import { RerollCalculatorModal } from "../../RerollCalculatorModal.tsx";
@@ -43,7 +44,8 @@ export default function Navbar() {
const setUseCardPeek = useCardStore(state => state.setUseCardPeek);
const maxMiscCardSource = useCardStore(state => state.applicationState.maxMiscCardSource);
const setMiscMaxSource = useCardStore(state => state.setMiscMaxSource);
- const maxAnte = useCardStore(state => state.applicationState.selectedAnte ?? 8);
+ const maxAnte = useCardStore(state => state.engineState.maxAnte);
+ const setMaxAnte = useCardStore(state => state.setMaxAnte);
const setSeed = useCardStore(state => state.setSeed);
const setDeck = useCardStore(state => state.setDeck);
@@ -59,7 +61,6 @@ export default function Navbar() {
const rerollCalculatorMetadata = useCardStore(state => state.applicationState.rerollCalculatorMetadata);
const closeRerollCalculatorModal = useCardStore(state => state.closeRerollCalculatorModal);
const reset = useCardStore(state => state.reset);
- const setSelectedAnte = useCardStore(state => state.setSelectedAnte);
const handleAnalyzeClick = () => {
setStart(true);
@@ -135,7 +136,7 @@ export default function Navbar() {
value={maxAnte}
onChange={(val) => {
const n = Number(val) || 8;
- setSelectedAnte(Math.max(1, Math.min(n, 39)));
+ setMaxAnte(Math.max(1, Math.min(n, 39)));
}}
min={1}
max={39}
@@ -187,6 +188,7 @@ export default function Navbar() {
]}
/>
+
Cards per Ante
diff --git a/src/components/blueprint/standardView/index.tsx b/src/components/blueprint/standardView/index.tsx
index 758e5f3..ccd8ae6 100644
--- a/src/components/blueprint/standardView/index.tsx
+++ b/src/components/blueprint/standardView/index.tsx
@@ -543,7 +543,6 @@ function Main() {
{SeedResults && viewMode === 'text' && }
{SeedResults && viewMode === 'simple' && }
{viewMode === 'jaml' && }
- {SeedResults && viewMode === 'blueprint' && }
{SeedResults && }
)
diff --git a/src/modules/state/analysisResultProvider.tsx b/src/modules/state/analysisResultProvider.tsx
index a88fda6..ed77993 100644
--- a/src/modules/state/analysisResultProvider.tsx
+++ b/src/modules/state/analysisResultProvider.tsx
@@ -2,8 +2,12 @@ import React, { createContext, useContext, useMemo } from "react";
import { analyzeSeed } from "../GameEngine/index.ts";
-import { useCardStore } from "./store.ts";
+import { startingDeckCards, useCardStore } from "./store.ts";
import { useSeedOptionsContainer } from "./optionsProvider.tsx";
+import type { AnalyzeOptions } from "../GameEngine/index.ts";
+import type { InitialState } from "./store.ts";
+import type { OptionsProviderProps } from "./optionsProvider.tsx";
+import type { DeckCard } from "../deckUtils.ts";
import type { SeedResultsContainer } from "../GameEngine/CardEngines/Cards.ts";
export const SeedResultContext = createContext(null);
@@ -16,17 +20,57 @@ export function useSeedResultsContainer() {
return context;
}
-
-// Simple in-memory cache
const resultCache = new Map();
-// Helper to generate a cache key from all influential state
-function getCacheKey(state: any, options: any, seed: string) {
- // We exclude seed from the base state object and append it explicitly
+type EngineState = InitialState['engineState'];
+
+function getCacheKey(state: EngineState, options: AnalyzeOptions, seed: string) {
const { seed: _seed, ...restState } = state;
return JSON.stringify({ ...restState, options, seed });
}
+function runAnalysis(state: EngineState, options: AnalyzeOptions, seed: string) {
+ const cacheKey = getCacheKey(state, options, seed);
+ const cached = resultCache.get(cacheKey);
+ if (cached) return cached;
+ const result = analyzeSeed({ ...state, seed, antes: state.maxAnte }, options);
+ if (result) resultCache.set(cacheKey, result);
+ return result;
+}
+
+function optionsFromStore(): AnalyzeOptions {
+ const s = useCardStore.getState();
+ return {
+ maxMiscCardSource: s.applicationState.maxMiscCardSource,
+ showCardSpoilers: s.applicationState.showCardSpoilers,
+ unlocks: s.engineState.selectedOptions,
+ events: s.eventState.events,
+ updates: [],
+ buys: s.shoppingState.buys,
+ sells: s.shoppingState.sells,
+ lockedCards: s.lockState.lockedCards,
+ customDeck: s.deckState.cards,
+ };
+}
+
+function optionsForSeed(seed: string, state: EngineState): AnalyzeOptions {
+ const options = optionsFromStore();
+ if (seed === state.seed) return options;
+ return { ...options, buys: {}, sells: {}, customDeck: startingDeckCards(seed, state) };
+}
+
+export function analyzeSeedFromStore(seed?: string, overrides?: Partial) {
+ const state = { ...useCardStore.getState().engineState, ...overrides };
+ const target = seed ?? state.seed;
+ if (!target) return undefined;
+ return runAnalysis(state, optionsForSeed(target, state), target);
+}
+
+export function prefetchSeedAnalysis(seed: string) {
+ const state = useCardStore.getState().engineState;
+ runAnalysis(state, optionsForSeed(seed, state), seed);
+}
+
export function SeedResultProvider({ children }: { children: React.ReactNode }) {
const start = useCardStore(state => state.applicationState.start);
const analyzeState = useCardStore(state => state.engineState);
@@ -37,29 +81,9 @@ export function SeedResultProvider({ children }: { children: React.ReactNode })
if (!start) {
return undefined;
}
-
- const cacheKey = getCacheKey(analyzeState, options, analyzeState.seed);
-
- if (resultCache.has(cacheKey)) {
- return resultCache.get(cacheKey);
- }
-
- // Analyze and cache
- const result = analyzeSeed(analyzeState, {
- ...options,
- customDeck: deckCards
- });
- if (result) {
- resultCache.set(cacheKey, result);
- }
- return result;
+ return runAnalysis(analyzeState, toAnalyzeOptions(options, deckCards), analyzeState.seed);
}, [analyzeState, deckCards, options, start]);
- // Expose a way to pre-warm the cache from other components
- // We attach this to the window or export a hook, but for now,
- // passing it via context or just exporting a function that reads the store might be cleaner.
- // For simplicity in this specialized app, we'll keep the cache global.
-
return (
{children}
@@ -67,14 +91,6 @@ export function SeedResultProvider({ children }: { children: React.ReactNode })
)
}
-export function prefetchSeedAnalysis(seed: string, state: any, options: any) {
- const cacheKey = getCacheKey(state, options, seed);
- if (resultCache.has(cacheKey)) return; // Already cached
-
- // Create a version of state with the new seed
- const newState = { ...state, seed };
- const result = analyzeSeed(newState, options);
- if (result) {
- resultCache.set(cacheKey, result);
- }
+function toAnalyzeOptions(options: OptionsProviderProps, customDeck: Array): AnalyzeOptions {
+ return { ...options, customDeck };
}
diff --git a/src/modules/state/jamlSearchContext.tsx b/src/modules/state/jamlSearchContext.tsx
index 4f20a17..24a519a 100644
--- a/src/modules/state/jamlSearchContext.tsx
+++ b/src/modules/state/jamlSearchContext.tsx
@@ -1,4 +1,6 @@
-import { createContext, useContext, useState } from 'react';
+import { createContext, useContext, useEffect, useRef, useState } from 'react';
+import { registerTools } from '../webmcp/registry.ts';
+import { useCardStore } from './store.ts';
import type { ReactNode } from 'react';
interface JamlSearchState {
@@ -59,6 +61,36 @@ export function JamlSearchProvider({ children }: { children: ReactNode }) {
const [selectedFilterKey, setSelectedFilterKey] = useState('default');
const [customJamlText, setCustomJamlText] = useState('');
+ const jamlRef = useRef(jamlText);
+ useEffect(() => {
+ jamlRef.current = jamlText;
+ }, [jamlText]);
+
+ useEffect(() => registerTools([
+ {
+ name: 'get_jaml_filter',
+ description: 'Read the JAML filter currently in the JAML search editor.',
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
+ execute: () => ({ jaml: jamlRef.current }),
+ },
+ {
+ name: 'set_jaml_filter',
+ description: 'Replace the JAML filter in the JAML search editor and switch to the JAML view. JAML is Jimbo\'s Ante Markup Language: name/deck/stake plus must/should/mustNot clause lists.',
+ inputSchema: {
+ type: 'object',
+ properties: { jaml: { type: 'string', minLength: 1 } },
+ required: ['jaml'],
+ additionalProperties: false,
+ },
+ execute: (input) => {
+ const jaml = String(input.jaml ?? '');
+ setJamlText(jaml);
+ useCardStore.getState().setViewMode('jaml');
+ return { lines: jaml.split('\n').length };
+ },
+ },
+ ]), []);
+
return (
;
+ index: number;
};
searchState: {
searchTerm: string;
@@ -105,6 +110,11 @@ interface StoreActions {
setSelectedBlind: (selectedBlind: Blinds) => void;
toggleSettings: () => void;
toggleOutput: () => void;
+ toggleAssistant: () => void;
+ setSeedQueue: (seeds: Array) => void;
+ jumpSeedQueue: (index: number) => void;
+ stepSeedQueue: (delta: number) => void;
+ clearSeedQueue: () => void;
setApiEndpoint: (apiEndpoint: string) => void;
setMiscSource: (source: string) => void;
setAsideTab: (tab: string) => void;
@@ -139,6 +149,68 @@ interface StoreActions {
reset: () => void;
}
export interface CardStore extends InitialState, StoreActions { }
+
+const SEED_QUEUE_KEY = 'blueprint-seed-queue';
+
+function loadSeedQueue(): InitialState['seedQueue'] {
+ try {
+ const raw = localStorage.getItem(SEED_QUEUE_KEY);
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ if (Array.isArray(parsed.seeds)) {
+ const index = Math.min(Math.max(0, Number(parsed.index) || 0), Math.max(0, parsed.seeds.length - 1));
+ return { seeds: parsed.seeds.filter((s: unknown) => typeof s === 'string'), index };
+ }
+ }
+ } catch {
+ return { seeds: [], index: 0 };
+ }
+ return { seeds: [], index: 0 };
+}
+
+function saveSeedQueue(queue: InitialState['seedQueue']) {
+ try {
+ localStorage.setItem(SEED_QUEUE_KEY, JSON.stringify(queue));
+ } catch {
+ return;
+ }
+}
+
+export function startingDeckCards(seed: string, engine: Pick): Array {
+ const params = new InstanceParams(
+ new Deck(deckMap[engine.deck]),
+ new Stake(stakeMap[engine.stake]),
+ engine.showmanOwned,
+ Number(engine.gameVersion)
+ );
+ return new Game(seed, params).initDeck().map((card, i) => convertGameCardToDeckCard(card, i));
+}
+
+export function parseSeedList(text: string): Array {
+ const seen = new Set();
+ const seeds: Array = [];
+ const push = (token: string) => {
+ const seed = sanitizeSeed(token);
+ if (seed && !seen.has(seed)) {
+ seen.add(seed);
+ seeds.push(seed);
+ }
+ };
+ for (const line of text.split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith('#')) continue;
+ if (/[,;|\t]/.test(trimmed)) {
+ const [first, ...rest] = trimmed.split(/[,;|\t]/).map((field) => field.trim());
+ if (first.toLowerCase() === 'seed') continue;
+ push(first);
+ rest.filter((field) => /[a-z]/i.test(field)).forEach(push);
+ } else {
+ trimmed.split(/\s+/).forEach(push);
+ }
+ }
+ return seeds;
+}
+
const initialState: InitialState = {
engineState: {
seed: '',
@@ -175,8 +247,10 @@ const initialState: InitialState = {
rerollStartIndex: 0,
drawSimulatorModalOpen: false,
conversionSourceId: null,
- apiEndpoint: 'https://motelyjaml-pi.8pi.me'
+ apiEndpoint: 'https://motelyjaml-pi.8pi.me',
+ assistantOpen: false,
},
+ seedQueue: loadSeedQueue(),
searchState: {
searchTerm: '',
searchResults: [],
@@ -302,17 +376,7 @@ export const useCardStore = create()(
prev.searchState = initialState.searchState;
prev.applicationState.hasSettingsChanged = true;
- const deckType = deckMap[prev.engineState.deck];
- const stakeType = stakeMap[prev.engineState.stake];
- const params = new InstanceParams(
- new Deck(deckType),
- new Stake(stakeType),
- prev.engineState.showmanOwned,
- Number(prev.engineState.gameVersion)
- );
- const game = new Game(prev.engineState.seed, params);
- const gameCards = game.initDeck();
- prev.deckState.cards = gameCards.map((card, i) => convertGameCardToDeckCard(card, i));
+ prev.deckState.cards = startingDeckCards(prev.engineState.seed, prev.engineState);
prev.deckState.isInitialized = true;
}, undefined, 'Global/SetSeed'),
setDeck: (deck: string) => set((prev) => {
@@ -322,18 +386,7 @@ export const useCardStore = create()(
// Create Game instance for deck initialization
// Only if we have a seed, otherwise use standard generation
if (prev.engineState.seed) {
- const deckType = deckMap[deck];
- const stakeType = stakeMap[prev.engineState.stake];
- const params = new InstanceParams(
- new Deck(deckType),
- new Stake(stakeType),
- prev.engineState.showmanOwned,
- Number(prev.engineState.gameVersion)
- );
- const game = new Game(prev.engineState.seed, params);
-
- const gameCards = game.initDeck();
- prev.deckState.cards = gameCards.map((card, i) => convertGameCardToDeckCard(card, i));
+ prev.deckState.cards = startingDeckCards(prev.engineState.seed, prev.engineState);
} else {
const starterDeck = generateStartingDeck(deck);
prev.deckState.cards = starterDeck;
@@ -426,6 +479,37 @@ export const useCardStore = create()(
toggleOutput: () => set((prev) => {
prev.applicationState.asideOpen = !prev.applicationState.asideOpen;
}, undefined, 'Global/ToggleOutput'),
+ toggleAssistant: () => set((prev) => {
+ prev.applicationState.assistantOpen = !prev.applicationState.assistantOpen;
+ }, undefined, 'Global/ToggleAssistant'),
+ setSeedQueue: (seeds) => {
+ set((prev) => {
+ prev.seedQueue = { seeds, index: 0 };
+ saveSeedQueue(prev.seedQueue);
+ }, undefined, 'SeedQueue/Set');
+ if (seeds.length > 0) get().jumpSeedQueue(0);
+ },
+ jumpSeedQueue: (index) => {
+ const { seeds } = get().seedQueue;
+ if (seeds.length === 0) return;
+ const clamped = Math.min(Math.max(0, Math.trunc(index)), seeds.length - 1);
+ get().setSeed(seeds[clamped]);
+ set((prev) => {
+ prev.seedQueue.index = clamped;
+ prev.applicationState.start = true;
+ prev.applicationState.viewMode = prev.applicationState.viewMode === 'jaml'
+ ? 'blueprint'
+ : prev.applicationState.viewMode;
+ saveSeedQueue(prev.seedQueue);
+ }, undefined, 'SeedQueue/Jump');
+ },
+ stepSeedQueue: (delta) => {
+ get().jumpSeedQueue(get().seedQueue.index + delta);
+ },
+ clearSeedQueue: () => set((prev) => {
+ prev.seedQueue = { seeds: [], index: 0 };
+ saveSeedQueue(prev.seedQueue);
+ }, undefined, 'SeedQueue/Clear'),
setApiEndpoint: (apiEndpoint) => set((prev) => {
prev.applicationState.apiEndpoint = apiEndpoint;
}, undefined, 'Global/SetApiEndpoint'),
@@ -629,7 +713,7 @@ export const useCardStore = create()(
buys: state.shoppingState.buys,
sells: state.shoppingState.sells
},
- applicationState: state.applicationState,
+ applicationState: { ...state.applicationState, assistantOpen: false },
searchState: state.searchState,
deckState: state.deckState
}),
diff --git a/src/modules/webmcp/knowledge.ts b/src/modules/webmcp/knowledge.ts
new file mode 100644
index 0000000..2e3537b
--- /dev/null
+++ b/src/modules/webmcp/knowledge.ts
@@ -0,0 +1,83 @@
+export const BALATRO_KNOWLEDGE = `
+## What Balatro is
+
+Balatro (LocalThunk, 2024) is a poker-themed roguelike deckbuilder: play poker hands from a 52-card deck to beat escalating "blinds"; Jokers, consumables and vouchers bend the rules.
+
+**Run structure.** A run is 8 Antes (Endless continues past 8). Each Ante has three blinds in order: **Small Blind** (1x base chips, reward $3), **Big Blind** (1.5x, $4), **Boss Blind** (2x, $5, plus a rule-breaking effect). White-stake base chips per Ante: 300, 800, 2000, 5000, 11000, 20000, 35000, 50000; Green/Purple stakes scale harder . Small and Big Blinds can be **skipped** for a **Tag** (you forfeit that blind's money and its shop). Boss Blinds cannot be skipped. Defaults: 4 hands, 3 discards, hand size 8, 5 Joker slots, 2 consumable slots, $4 starting money. Money earns interest: $1 per $5 held, capped at $5 (i.e. $25 held); Seed Money/Money Tree raise the cap.
+
+**Scoring.** Score = Chips × Mult. Each played hand type has a base (e.g. High Card 5×1, Pair 10×2, Flush 35×4) that Planet cards level up. Scored cards add their chip value; Jokers add +Chips, +Mult, or ×Mult. Evaluation is left to right across Jokers and scored cards, so order matters for copy/retrigger Jokers. Flat +Mult wins early; only ×Mult and retriggers keep pace after Ante ~4. The IEEE-754 overflow at ~1e308 displays as **naneinf**; Plasma Deck (score = ((Chips+Mult)/2)²) needs only ~1e154 Mult to reach it.
+
+**Shops.** After each non-skipped blind: 2 card slots (Overstock/Overstock Plus add up to 2 more), 1 voucher slot (restocks after each Boss), 2 booster-pack slots. Reroll costs $5, +$1 per reroll, resets each shop (Reroll Surplus/Glut reduce it). Card-slot pool weights: Joker 20, Tarot 4, Planet 4; playing cards only with Magic Trick, Spectrals only on Ghost Deck . Shop Joker rarity: 70% Common, 25% Uncommon, 5% Rare; Legendaries never appear in shops. Shop edition odds: Foil 2%, Holographic 1.4%, Polychrome 0.3%, Negative 0.3% (Hone ×2, Glow Up ×4) . Packs: Standard (playing cards), Arcana (Tarot), Celestial (Planet), Buffoon (Jokers), Spectral; sizes Normal (choose 1 of 3, $4), Jumbo (1 of 5, $6), Mega (2 of 5, $8). The first shop of a run always contains a Buffoon pack, but it is a normal 1-of-3 pack, not a Mega. Slang to normalize: "tarot pack" = Arcana, "planet pack" = Celestial, "joker pack" = Buffoon, "ghost pack" = Spectral, "playing card pack" = Standard.
+
+**Vouchers (32, 16 pairs; tier 2 requires tier 1 bought, so earliest is Ante 2).** Overstock → Overstock Plus (shop slots); Clearance Sale → Liquidation (25%/50% off); Hone → Glow Up (edition rates); Reroll Surplus → Reroll Glut; Crystal Ball → Omen Globe (+1 consumable slot; Spectrals in Arcana packs); Telescope → Observatory (Celestial packs contain your most-played hand's Planet; held Planets give ×1.5 to their hand); Grabber → Nacho Tong (+hands); Wasteful → Recyclomancy (+discards); Tarot Merchant → Tarot Tycoon; Planet Merchant → Planet Tycoon; Director's Cut → Retcon (reroll boss once per Ante $10 / unlimited); Paint Brush → Palette (+hand size); Magic Trick → Illusion (playing cards in shop, then with modifiers); Blank → Antimatter (nothing → +1 Joker slot); Seed Money → Money Tree (interest cap $10/$20); Hieroglyph → Petroglyph (Ante −1, costs a hand/discard; enables "ante 0").
+
+**Consumables.** Tarot (22): The Fool (copy last Tarot/Planet), Magician (Lucky), High Priestess (2 Planets), Empress (Mult), Emperor (2 Tarots), Hierophant (Bonus), Lovers (Wild), Chariot (Steel), Justice (Glass), Hermit (double money, max $20), Wheel of Fortune (1 in 4 edition on a Joker), Strength (+1 rank), Hanged Man (destroy 2), Death (copy right card onto left), Temperance (Joker sell value, max $50), Devil (Gold), Tower (Stone), Star/Moon/Sun/World (3 cards to Diamonds/Clubs/Hearts/Spades), Judgement (random Joker). Planet (12): Pluto High Card, Mercury Pair, Uranus Two Pair, Venus Three of a Kind, Saturn Straight, Jupiter Flush, Earth Full House, Mars Four of a Kind, Neptune Straight Flush, Planet X Five of a Kind, Ceres Flush House, Eris Flush Five. Spectral (18): Familiar/Grim/Incantation (destroy 1, add enhanced face cards/Aces/numbers), Talisman (Gold Seal), Aura (edition), Deja Vu (Red Seal), Trance (Blue Seal), Medium (Purple Seal), Ouija (one rank, −1 hand size), Sigil (one suit), Cryptid (2 copies of a card), Wraith (Rare Joker, money to $0), Immolate (destroy 5, +$20), Ankh (copy a random Joker, destroy the rest), Hex (Polychrome on a random Joker, destroy the rest), Ectoplasm (Negative on a Joker, −1 hand size), **The Soul** (Legendary Joker), **Black Hole** (all hands +1 level). The Soul has a 0.3% chance per card generated in Arcana and Spectral packs; Black Hole 0.3% in Celestial and Spectral packs. The game avoids offering a consumable you already hold unless you have Showman.
+
+**Enhancements/Seals/Editions.** Enhancements: Bonus +30 chips, Mult +4, Wild (any suit), Glass ×2 (1 in 4 to shatter), Steel ×1.5 while held in hand, Stone +50 chips no rank/suit, Gold $3 if held at end of round, Lucky (1 in 5 +20 Mult, 1 in 15 $20). Seals: Red (retrigger once), Blue (Planet of the final hand if held), Gold ($3 when scored), Purple (Tarot when discarded). Editions: Foil +50 chips, Holographic +10 Mult, Polychrome ×1.5 Mult, **Negative** (+1 Joker slot; on consumables +1 consumable slot; the Negative card occupies no net space).
+
+**Tags (24, skip rewards).** Uncommon/Rare Tag (free Uncommon/Rare Joker in next shop); Negative/Foil/Holographic/Polychrome Tag (the **next base-edition Joker in a shop** becomes free with that edition; the tag waits if none is present); Investment ($25 after next Boss); Voucher (extra voucher in next shop); Boss (reroll boss); Standard/Charm/Meteor/Buffoon (free Mega Standard/Arcana/Celestial/Buffoon pack); Ethereal (free Spectral pack); Handy ($1 per hand played this run); Garbage ($1 per unused discard); Coupon (next shop's initial items free); Double (copies the next tag chosen); Juggle (+3 hand size next round); D6 (rerolls start at $0); Top-up (up to 2 Common Jokers); Speed ($5 per skipped blind); Orbital (+3 levels to a random hand); Economy (double money, max +$40). Tags are attached to each Small/Big Blind at generation; blinds do not "grant" tags for winning. : Negative, Foil, Holographic, Polychrome, Standard, Meteor, Buffoon, Handy, Garbage, Ethereal, Top-up and Orbital have a minimum Ante of 2, so an "Ante 1 Negative Tag" filter should return nothing.
+
+**Legendary Jokers (only via The Soul):** Canio (×Mult when a face card is destroyed; spelling "Caino" is wrong), Triboulet (×2 per scored King/Queen), Yorick (×1 per 23 discards), Chicot (disables Boss Blind effects), Perkeo (Negative copy of a random held consumable at end of shop).
+
+**Decks (15).** Red +1 discard; Blue +1 hand; Yellow +$10; Green no interest, $2/hand and $1/discard left; Black +1 Joker slot, −1 hand; Magic (Crystal Ball, 2× The Fool); Nebula (Telescope, −1 consumable slot); Ghost (Spectrals in shop, starts with Hex); Abandoned (40 cards, no face cards); Checkered (26 Spades, 26 Hearts); Zodiac (Tarot Merchant, Planet Merchant, Overstock); Painted (+2 hand size, −1 Joker slot); Anaglyph (Double Tag per Boss defeated); Plasma (averaged scoring, blinds ×2); Erratic (random ranks/suits, the only deck where \`ErraticSuit\`/\`ErraticRank\` filters apply). "Challenge" is a mode, not a deck, and cannot be seeded.
+
+**Boss Blinds (28; 23 regular, 5 Ante-8 finishers).** Debuffers: The Club/Goad/Window/Head (Clubs/Spades/Diamonds/Hearts), The Plant (face cards), The Pillar (cards played earlier this Ante). Restrictors: The Psychic (exactly 5 cards), The Eye (no repeat hand types), The Mouth (one hand type), The Needle (one hand). Hidden info: The House (first hand face down), The Wheel (1 in 7 face down), The Fish (draws face down), The Mark (face cards face down). Scalers: The Wall (4x chips), The Flint (halves base chips and mult). Others: The Ox ($0 if you play your most-played hand), The Tooth (−$1 per card played), The Arm (−1 hand level), The Hook (discards 2 after each play), The Manacle (−1 hand size), The Water (no discards), The Serpent (always draw 3 after play/discard). Finishers: Amber Acorn (flips/shuffles Jokers), Cerulean Bell (forces a card selected), Crimson Heart (disables a random Joker each hand), Verdant Leaf (all cards debuffed until you sell a Joker), Violet Vessel (3x chips).
+
+**Stakes (8).** White base; Red no Small Blind money; Green faster blind scaling; Black Eternal stickers (can't sell/destroy, ~30%); Blue −1 discard; Purple faster scaling; Orange Perishable stickers (debuffed after 5 rounds); Gold Rental stickers ($3 per round). Stickers gate strategies: Eternal Invisible Joker/Luchador are useless; a Perishable scaler dies before paying off.
+
+**Seeds and RNG.** A seed is 1–8 characters from \`1-9A-Z\` (no zero): 35⁸ ≈ 2.3 trillion seeds ****. Every random event is a pseudo-hash of (a string key + the seed), so each domain has its own independent stream: shop queue per Ante, pack contents, card draw, boss selection, tag selection, edition rolls, and "1 in X" probability rolls (Wheel of Fortune, Lucky cards, Glass) each advance only their own key. Shop queues are keyed by Ante: viewing/rerolling advances that Ante's queue, and 5 rerolls then 7 gives the same sequence as 7 then 5; skipping doesn't skip that Ante's queue. Player choices (buying, rerolling, opening packs) consume queue positions, so an analyzer shows the queue for a given set of choices and Blueprint's "buy" simulation advances it. Same seed + deck + stake + game version (1.0.0n, 1.0.1c, 1.0.1f) + same choices = same run. Unlocks matter: a locked card rolls over to something else on that profile, and Legendaries require having found a Soul once unseeded. \`src/modules/balatrots/enum/\` in Blueprint holds the exact PRNG key strings; never alter them.
+
+## Seed-hunting strategy and terminology
+
+- **God seed**: a seed whose queues line up for a specific build. Prioritise what makes the run (the legendary/engine + its ante window) and rank the rest.
+- **The Soul / "soul card"**: the only Legendary source; hunt it in Arcana/Spectral packs (Mega packs = more cards = more 0.3% rolls). Perkeo can duplicate a held Soul.
+- **negleg**: shorthand for "Negative Legendary" searches (Blueprint README mentions ~8M seeds/s on that filter).
+- **Perkeo loops**: hold one Cryptid → Negative Cryptids every shop → duplicate Red-Seal Steel Kings into 90-card hands; hold Pluto + Observatory → (1.5)^N on High Card ("Observatory infinite"); Blueprint/Brainstorm on Perkeo multiplies the copies.
+- **Baron + Mime** (Steel Kings, Red Seals): (1.5)^4 per King; plus Serpent boss + Burglar to draw the whole deck; the standard naneinf engine. Killed by **The Plant** (debuffs face cards) unless Director's Cut/Retcon is available.
+- **Blueprint/Brainstorm copy**: Blueprint copies the Joker to its right, Brainstorm the leftmost. Copying the ×Mult (Photograph) beats copying the retrigger (Chad): Photo+Chad ×8, Blueprint-on-Photo ×64, Blueprint-on-Chad ×32. Order rule: enabler left of payoff (Midas Mask before Vampire).
+- **PhotoChad**: Photograph + Hanging Chad, $9 for ×8; Glass ×64, Red Seal ×128–256.
+- **Triboulet/Idol engine**: Sock & Buskin or Hanging Chad retriggers on Red-Seal Glass Kings/Queens; The Idol needs a homogenized deck (Death, Hanged Man, Cryptid, Trading Card).
+- **Ankh / Invisible Joker / Showman**: Ankh copies one random Joker and destroys the rest (classic early double-Blueprint); Invisible Joker sold after 2 rounds duplicates a random Joker (only way to double a Legendary); Showman allows duplicates in shops/packs. Selling a Negative Joker while at the cap causes **slot debt** (over cap, can't buy).
+- **Diet Cola / Double Tag stacking**: selling Diet Cola creates a Double Tag; Double Tags stack, so a pile of them cashed on a Negative Tag yields many free Negative Jokers (Anaglyph Deck does this natively).
+- **Tarot looping**: hold Negative copies of all Tarots except Emperor/Fool/Judgement (no Showman) so Emperor always yields Fool + Judgement; Fool re-creates Emperor; each Judgement has 0.3% Negative chance. "150 negatives" screenshot build.
+- **Chicot × Manacle**: multiple Chicots reportedly turn Manacle's −1 hand size into +1 [unverified].
+- **Oops! All 6s "casino"**: doubles listed probability numerators (Bloodstone 1 in 2 → guaranteed, Glass breaks 50%, Wheel boss 2 in 7).
+- **Vampire + Midas Mask**, **Merry Andy + Hit the Road**, **DNA + Hologram**, **Hack + Wee Joker/Fibonacci**: enabler → payoff pairs. Anti-pairs: Vampire + Glass, Madness/Ceremonial Dagger + Eternal, Ride the Bus + Sock & Buskin, Joker Stencil + full board.
+- **Phase rule**: Antes 1–2 need an anchor (+Mult/+Chips: Joker, Sly, Half, Gros Michel); Antes 3–5 need scalers (Constellation, Green Joker, Ride the Bus, Hologram) acquired early enough to grow; Antes 6–8 need 2–3 ×Mult sources. A late Constellation is a trap.
+- **Community seeds [unverified]**: ALEEB (all 5 Legendaries by Ante 3, no skips), 11Y68BMP (double Polychrome Brainstorm + Holo Perkeo Ante 1), MIPK41M6 (Ghost: only Rares and Spectral packs), DCVE28EC (Ghost: Perkeo in Ante 1 shop), 8Q47WV6K (skip both Ante 1 blinds: Triboulet then Perkeo from Mega Arcana packs), 7M1GURT (63 Wheel of Fortune fails), I3EFEF17 (Erratic: 25 Tens), LWQW78CB (early Baron+Mime), YR98CK4B (Negative Perkeo Ante 1); seed index: LEGEJS53 (double Rare Tag), CASHCASH (Spectral tags), GLASS/X3DISXVS (DNA), FULL (Joker Stencil), AYO (Ouija all Kings).
+
+## JAML, Motely, seedfinder.app, and Blueprint
+
+**Motely** is the C# seed-search engine (by tacodiva, commissioned by OptimusPi/pifreak): AVX-512 SIMD, 8 seeds per vector per thread, the fastest CPU searcher. **motely-wasm** is its browser/Node build; v11+ is NativeAOT-LLVM, single-threaded, no SharedArrayBuffer/COI worker, boot once. Blueprint currently pins \`motely-wasm ^26.0.0\` and \`jaml-ui ^4.6.0\`. Search runs client-side in the browser; the MCP server never runs a search. FIXES.md (v22 era) documents: \`Program.jamlyze(seed, deck, stake)\` returns shopQueue, packs[].items, grantedLegendaryJoker and tag-granted Jokers per ante (~1200 seeds/s headless); items are integers with a rarity-paged encoding — \`(v>>8)&0xF0 == 0x50\` marks a Joker, \`(v>>8)&0x0F\` is rarity (0 common, 4 uncommon, 8 rare, C legendary), \`v&0xFF\` indexes the rarity-specific enum, \`(v>>16)&0xFF\` is edition (0x80 Negative); e.g. Blueprint 0x5807, Brainstorm 0x5811, Perkeo 0x5C04. \`jaml-lang\` is the language server (diagnostics/completions/hover), not a compiler; \`jaml-codemirror\` wires it into CodeMirror 6; \`jaml-ui\` provides JamlIde/JamlGameCard components.
+
+**JAML (Jimbo's Ante Markup Language)** is a strict YAML-shaped filter DSL validated at load (schema \`https://mcp.seedfinder.app/jaml.schema.json\`, generated from Motely DTOs; VS Code extension \`pifreak.jaml-language-support\`).
+- Top-level keys: \`name\`, \`deck\`, \`stake\` (plus \`id\`, \`version\`, \`author\`, \`description\`), and clause groups \`must\` (hard gates), \`should\` (add \`score: N\`, summed to rank), \`mustNot\` (disqualifiers). At least one group required.
+- Clause discriminators, PascalCase values: \`joker\` (150), \`rareJoker\`/\`uncommonJoker\`/\`commonJoker\` (same match plus a parse-time rarity check), \`legendaryJoker\` (Canio, Triboulet, Yorick, Chicot, Perkeo), \`voucher\` (32), \`tarotCard\` (22), \`spectralCard\` (18), \`planetCard\` (12), \`boss\` (28), \`tag\` (24), \`smallBlindTag\`/\`bigBlindTag\`, \`standardCard\` (with \`enhancement\`/\`seal\`/\`edition\`), and event keys (\`wheelStaysFlipped\`, \`bloodstoneTrigger\`, \`luckyMoney\`, \`businessPayout\`, \`parkingPayout\`, \`misprintMult\` with \`mult:N\`) whose value is a roll-index array with \`min\`/\`max\`.
+- Shared props: \`antes: [..]\` (0–39; 0 via Hieroglyph), \`score\`, \`label\`, \`edition\` (Negative/Foil/Holographic/Polychrome), \`seal\`, \`enhancement\`, \`sources: {shopItems: [0-7], boosterPacks: [0-5], arcanaPacks, spectralPacks}\`, \`min\`.
+- Defaults: a \`joker:\` clause with no \`sources:\` checks **shop slots only**; legendaries **never** match shopItems and must use \`legendaryJoker:\` with \`boosterPacks\` (or arcana/spectral pack) sources. Tier-2 vouchers can't appear before Ante 2.
+- Names: \`JokerStencil\` (not Stencil Joker), \`EightBall\`/"8 Ball", \`OopsAll6s\`, \`TheSoul\`, \`BlackHole\`, \`DirectorsCut\`, \`GlowUp\`, \`NegativeTag\`, \`ThePlant\`, \`TheSerpent\`, \`VioletVessel\`; decks \`Red…Erratic\`; stakes \`White…Gold\`.
+- **JUMMY** one-liners (\`Blueprint in antes 1 or 2\`, \`Eternal Blueprint in Ante 1\`, \`Perkeo in antes 1, 2, or 3\`) are shorthand for the same clause; they cannot express \`sources:\`.
+- Tips from the docs: keep \`must\` short and early-ante (every clause is a SIMD gate; ten must-jokers = zero hits); push synergy pieces into \`should\` with weights (e.g. Baron must, Mime/Blueprint/Brainstorm/Telescope should); reserve \`mustNot\` for fatal counters like \`boss: ThePlant\` on a face-card build rather than "no Anaglyph". A validated-but-zero-hit filter is usually over-gated. Keyword seed search (e.g. seeds containing PIFREAK) is a search-provider feature, not a JAML key.
+
+Example (from the corpus):
+\`\`\`yaml
+name: Perkeo — negative consumables
+deck: Red
+stake: White
+must:
+ - legendaryJoker: Perkeo
+ antes: [1]
+ sources: { arcanaPacks: [0, 1], spectralPacks: [0, 1] }
+should:
+ - spectralCard: Cryptid
+ antes: [1, 2, 3]
+ score: 45
+ - joker: Blueprint
+ antes: [1, 2]
+ score: 40
+\`\`\`
+
+**seedfinder.app / MCP.** \`mcp.seedfinder.app/mcp\` (server "balatro-seed-oracle"; the earlier "balatro-seedfinder" had get_jaml_schema/validate_jaml/describe_filter/analyze_seeds/open_filter_editor) exposes learn_jaml → plan_seed_search → validate_jaml → show_seedfinder_app (loads a draft into the app editor), plus score_seeds/jamlyze_seeds/find_similar_seeds for known seeds, estimate_keyword_search, save_filter/list_filters/get_filter (Neon), save_seed/list_seeds, and community search parties that split the 2.3T keyspace by block range. Full-keyspace grinding runs on the user's CPU via motely-wasm. A past bug remounted the app iframe once per widget tool call; the fix is one stable \`ui://\` template per tool with data via notifications (MCP Apps, SEP-1865). "The Long Blind" is the Next.js/R3F frontend in the patch doc; \`BalatroSeedOracle.x64.2.0.0.exe\` is the older desktop searcher.
+
+**Blueprint (this app).** "Blueprint – pifreak's version": a Vite + Mantine React fork of Blueprint (miaklwalker), itself derived from MathIsFun0's The Soul (\`immolate.js\`). It takes a seed, deck, stake and version and reconstructs the run per Ante: shop queue, vouchers, tags, boss blind, booster packs and their contents, with a "JAML Mapped" view, in-browser motely-wasm search, and TXT/CSV seed-list upload. "Buying" in the simulator advances the relevant queue so later shops/packs match the live game. The assistant should describe what a given Ante's queue shows, translate wishes into JAML using the rules above, and note unlock/version caveats when a user can't reproduce a seed.
+`;
diff --git a/src/modules/webmcp/memory.ts b/src/modules/webmcp/memory.ts
new file mode 100644
index 0000000..5f55fb6
--- /dev/null
+++ b/src/modules/webmcp/memory.ts
@@ -0,0 +1,34 @@
+const MEMORY_KEY = "blueprint-assistant-memory";
+const MAX_NOTES = 40;
+
+export function listMemory(): Array {
+ try {
+ const parsed = JSON.parse(localStorage.getItem(MEMORY_KEY) ?? "[]");
+ return Array.isArray(parsed) ? parsed.filter((n) => typeof n === "string") : [];
+ } catch {
+ return [];
+ }
+}
+
+export function remember(note: string): Array {
+ const trimmed = note.trim();
+ const notes = listMemory().filter((n) => n !== trimmed);
+ if (trimmed) notes.push(trimmed);
+ const kept = notes.slice(-MAX_NOTES);
+ try {
+ localStorage.setItem(MEMORY_KEY, JSON.stringify(kept));
+ } catch {
+ return kept;
+ }
+ return kept;
+}
+
+export function forget(note?: string): Array {
+ const kept = note ? listMemory().filter((n) => n !== note) : [];
+ try {
+ localStorage.setItem(MEMORY_KEY, JSON.stringify(kept));
+ } catch {
+ return kept;
+ }
+ return kept;
+}
diff --git a/src/modules/webmcp/register.ts b/src/modules/webmcp/register.ts
new file mode 100644
index 0000000..3c39a12
--- /dev/null
+++ b/src/modules/webmcp/register.ts
@@ -0,0 +1,63 @@
+import { initializeWebMCPPolyfill } from "@mcp-b/webmcp-polyfill";
+import { listTools, onToolsChanged, registerTools } from "./registry.ts";
+import { blueprintTools } from "./tools.ts";
+import type { BlueprintTool } from "./registry.ts";
+
+interface ModelContextLike {
+ registerTool: (
+ tool: {
+ name: string;
+ description: string;
+ inputSchema: BlueprintTool["inputSchema"];
+ annotations?: { readOnlyHint?: boolean; consequentialHint?: boolean };
+ execute: (input: Record, options?: { signal?: AbortSignal }) => Promise;
+ },
+ options?: { signal?: AbortSignal },
+ ) => Promise;
+}
+
+const READ_ONLY = new Set(["get_app_state", "get_seed_overview", "get_ante_details", "find_items", "list_purchases", "get_jaml_filter"]);
+
+function modelContext(): ModelContextLike | undefined {
+ const doc = document as Document & { modelContext?: ModelContextLike };
+ if (doc.modelContext?.registerTool) return doc.modelContext;
+ const nav = navigator as Navigator & { modelContext?: ModelContextLike };
+ return nav.modelContext?.registerTool ? nav.modelContext : undefined;
+}
+
+let controller: AbortController | null = null;
+
+async function publish() {
+ const ctx = modelContext();
+ if (!ctx) return;
+ controller?.abort();
+ controller = new AbortController();
+ const { signal } = controller;
+ for (const tool of listTools()) {
+ try {
+ await ctx.registerTool(
+ {
+ name: tool.name,
+ description: tool.description,
+ inputSchema: tool.inputSchema,
+ annotations: { readOnlyHint: READ_ONLY.has(tool.name), consequentialHint: !READ_ONLY.has(tool.name) },
+ execute: async (input) => JSON.stringify(await tool.execute(input ?? {})),
+ },
+ { signal },
+ );
+ } catch (error) {
+ console.warn(`WebMCP: could not register ${tool.name}`, error);
+ }
+ }
+}
+
+let started = false;
+
+export function startWebMcp() {
+ if (started || typeof document === "undefined") return;
+ started = true;
+ initializeWebMCPPolyfill();
+ registerTools(blueprintTools);
+ onToolsChanged(() => void publish());
+ void publish();
+}
diff --git a/src/modules/webmcp/registry.ts b/src/modules/webmcp/registry.ts
new file mode 100644
index 0000000..129e6ad
--- /dev/null
+++ b/src/modules/webmcp/registry.ts
@@ -0,0 +1,51 @@
+export interface ToolInputSchema {
+ type: "object";
+ properties?: Record;
+ required?: Array;
+ additionalProperties?: boolean;
+}
+
+export interface BlueprintTool {
+ name: string;
+ description: string;
+ inputSchema: ToolInputSchema;
+ execute: (input: Record) => unknown | Promise;
+}
+
+const tools = new Map();
+const listeners = new Set<() => void>();
+
+function notify() {
+ for (const listener of listeners) listener();
+}
+
+export function registerTool(tool: BlueprintTool): () => void {
+ tools.set(tool.name, tool);
+ notify();
+ return () => {
+ if (tools.get(tool.name) === tool) {
+ tools.delete(tool.name);
+ notify();
+ }
+ };
+}
+
+export function registerTools(list: Array): () => void {
+ const unregister = list.map(registerTool);
+ return () => unregister.forEach((fn) => fn());
+}
+
+export function listTools(): Array {
+ return Array.from(tools.values());
+}
+
+export function onToolsChanged(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+export function callTool(name: string, input: Record = {}): Promise {
+ const tool = tools.get(name);
+ if (!tool) return Promise.reject(new Error(`Unknown tool: ${name}`));
+ return Promise.resolve(tool.execute(input));
+}
diff --git a/src/modules/webmcp/summary.ts b/src/modules/webmcp/summary.ts
new file mode 100644
index 0000000..3a79c54
--- /dev/null
+++ b/src/modules/webmcp/summary.ts
@@ -0,0 +1,126 @@
+import type { Ante, CardTuple, SeedResultsContainer, Stringifies } from "../GameEngine/CardEngines/Cards.ts";
+import type { Blinds } from "../state/store.ts";
+
+export type ItemSource = "shop" | "pack" | "misc" | "voucher" | "tag" | "boss";
+
+export interface ItemLocation {
+ ante: number;
+ source: ItemSource;
+ name: string;
+ edition?: string;
+ index?: number;
+ blind?: Blinds;
+ packIndex?: number;
+ packName?: string;
+ miscSource?: string;
+}
+
+const BLINDS: Array = ["smallBlind", "bigBlind", "bossBlind"];
+
+function label(card: Stringifies | CardTuple | undefined): string {
+ if (!card) return "";
+ const edition = card.edition && card.edition !== "No Edition" ? ` (${card.edition})` : "";
+ return `${card.name}${edition}`;
+}
+
+function sortedAntes(results: SeedResultsContainer): Array {
+ return Object.values(results.antes).sort((a, b) => a.ante - b.ante);
+}
+
+export function summarizeSeed(seed: string, results: SeedResultsContainer, shopLimit = 12) {
+ return {
+ seed,
+ antes: sortedAntes(results).map((ante) => ({
+ ante: ante.ante,
+ boss: ante.boss,
+ voucher: ante.voucher,
+ tags: ante.tags,
+ shop: ante.queue.slice(0, shopLimit).map(label),
+ shopSize: ante.queue.length,
+ packs: BLINDS.flatMap((blind) =>
+ ante.blinds[blind].packs.map((pack) => ({
+ blind,
+ pack: pack.name,
+ cards: pack.cards.map(label),
+ })),
+ ),
+ })),
+ };
+}
+
+export function describeAnte(ante: Ante) {
+ return {
+ ante: ante.ante,
+ boss: ante.boss,
+ voucher: ante.voucher,
+ tags: ante.tags,
+ shop: ante.queue.map((card, index) => ({ index, item: label(card), type: card.type })),
+ packs: BLINDS.flatMap((blind) =>
+ ante.blinds[blind].packs.map((pack, packIndex) => ({
+ blind,
+ packIndex,
+ pack: pack.name,
+ choices: pack.choices,
+ cards: pack.cards.map((card, index) => ({ index, item: label(card) })),
+ })),
+ ),
+ miscSources: ante.miscCardSources.map((source) => ({
+ source: source.name,
+ cards: source.cards.slice(0, 15).map((card) => card.joker ?? card.name ?? card.base ?? ""),
+ })),
+ };
+}
+
+function matches(needle: string, name: string | null | undefined): boolean {
+ return !!name && name.toLowerCase().includes(needle);
+}
+
+export function findItems(results: SeedResultsContainer, names: Array, maxPerName = 25): Record> {
+ const out: Record> = {};
+ for (const raw of names) {
+ const needle = raw.trim().toLowerCase();
+ if (!needle) continue;
+ const hits: Array = [];
+ for (const ante of sortedAntes(results)) {
+ if (matches(needle, ante.voucher)) hits.push({ ante: ante.ante, source: "voucher", name: ante.voucher! });
+ if (matches(needle, ante.boss)) hits.push({ ante: ante.ante, source: "boss", name: ante.boss! });
+ ante.tags.forEach((tag) => {
+ if (matches(needle, tag)) hits.push({ ante: ante.ante, source: "tag", name: tag });
+ });
+ ante.queue.forEach((card, index) => {
+ if (matches(needle, card.name)) {
+ hits.push({ ante: ante.ante, source: "shop", name: card.name, edition: card.edition, index });
+ }
+ });
+ for (const blind of BLINDS) {
+ ante.blinds[blind].packs.forEach((pack, packIndex) => {
+ pack.cards.forEach((card, index) => {
+ if (card && matches(needle, card.name)) {
+ hits.push({
+ ante: ante.ante,
+ source: "pack",
+ name: card.name,
+ edition: card.edition,
+ index,
+ blind,
+ packIndex,
+ packName: pack.name,
+ });
+ }
+ });
+ });
+ }
+ for (const misc of ante.miscCardSources) {
+ misc.cards.forEach((card, index) => {
+ const name = card.joker ?? card.name;
+ if (matches(needle, name)) {
+ hits.push({ ante: ante.ante, source: "misc", name: name, edition: card.edition, index, miscSource: misc.name });
+ }
+ });
+ }
+ if (hits.length >= maxPerName) break;
+ }
+ out[raw] = hits.slice(0, maxPerName);
+ }
+ return out;
+}
diff --git a/src/modules/webmcp/tools.ts b/src/modules/webmcp/tools.ts
new file mode 100644
index 0000000..7a00078
--- /dev/null
+++ b/src/modules/webmcp/tools.ts
@@ -0,0 +1,337 @@
+import { LOCATIONS, LOCATION_TYPES } from "../const.ts";
+import { BuyMetaData } from "../classes/BuyMetaData.ts";
+import { parseSeedList, useCardStore } from "../state/store.ts";
+import { analyzeSeedFromStore } from "../state/analysisResultProvider.tsx";
+import { sanitizeSeed } from "../utils.ts";
+import { describeAnte, findItems, summarizeSeed } from "./summary.ts";
+import { listMemory, remember } from "./memory.ts";
+import type { Blinds } from "../state/store.ts";
+import type { BlueprintTool } from "./registry.ts";
+import type { CardTuple } from "../GameEngine/CardEngines/Cards.ts";
+
+const DECKS = [
+ "Red Deck", "Blue Deck", "Yellow Deck", "Green Deck", "Black Deck", "Magic Deck", "Nebula Deck",
+ "Ghost Deck", "Abandoned Deck", "Checkered Deck", "Zodiac Deck", "Painted Deck", "Anaglyph Deck",
+ "Plasma Deck", "Erratic Deck",
+];
+const STAKES = ["White Stake", "Red Stake", "Green Stake", "Black Stake", "Blue Stake"];
+const BLINDS: Array = ["smallBlind", "bigBlind", "bossBlind"];
+
+function normalizeName(value: unknown, options: Array, suffix: string): string | undefined {
+ if (typeof value !== "string" || !value.trim()) return undefined;
+ const wanted = value.trim().toLowerCase().replace(new RegExp(`\\s*${suffix}$`), "");
+ return options.find((option) => option.toLowerCase().replace(new RegExp(`\\s*${suffix}$`), "") === wanted);
+}
+
+function currentAnalysis(seed?: unknown) {
+ const target = typeof seed === "string" && seed.trim() ? sanitizeSeed(seed) : undefined;
+ const results = analyzeSeedFromStore(target);
+ if (!results) throw new Error("No seed is loaded. Call analyze_seed with a seed first.");
+ return { seed: target ?? useCardStore.getState().engineState.seed, results };
+}
+
+function anteNumber(value: unknown): number {
+ const ante = Math.trunc(Number(value));
+ if (!Number.isFinite(ante) || ante < 0) throw new Error("ante must be a non-negative integer");
+ return ante;
+}
+
+function appState() {
+ const s = useCardStore.getState();
+ return {
+ seed: s.engineState.seed,
+ deck: s.engineState.deck,
+ stake: s.engineState.stake,
+ maxAnte: s.engineState.maxAnte,
+ cardsPerAnte: s.engineState.cardsPerAnte,
+ gameVersion: s.engineState.gameVersion,
+ view: s.applicationState.viewMode,
+ analyzed: s.applicationState.start && !!s.engineState.seed,
+ selectedAnte: s.applicationState.selectedAnte,
+ selectedBlind: s.applicationState.selectedBlind,
+ purchases: Object.keys(s.shoppingState.buys).length,
+ seedQueue: { count: s.seedQueue.seeds.length, index: s.seedQueue.index },
+ memory: listMemory(),
+ };
+}
+
+export const blueprintTools: Array = [
+ {
+ name: "get_app_state",
+ description: "Current Blueprint state: loaded seed, deck, stake, ante range, active view, selected ante/blind, purchase count, seed queue position, and saved user notes.",
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
+ execute: () => appState(),
+ },
+ {
+ name: "analyze_seed",
+ description: "Load a Balatro seed into Blueprint and analyze it (shop queues, packs, vouchers, tags, bosses per ante). Optionally set deck, stake and how many antes to generate. Returns a compact per-ante overview.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ seed: { type: "string", description: "1-8 characters, A-Z and 1-9 (no zero)." },
+ deck: { type: "string", enum: DECKS },
+ stake: { type: "string", enum: STAKES },
+ maxAnte: { type: "integer", minimum: 1, maximum: 39 },
+ },
+ required: ["seed"],
+ additionalProperties: false,
+ },
+ execute: (input) => {
+ const seed = sanitizeSeed(String(input.seed ?? ""));
+ if (!seed) throw new Error("seed must contain at least one of A-Z or 1-9");
+ const s = useCardStore.getState();
+ const deck = normalizeName(input.deck, DECKS, "deck");
+ const stake = normalizeName(input.stake, STAKES, "stake");
+ if (deck) s.setDeck(deck);
+ if (stake) s.setStake(stake);
+ if (input.maxAnte !== undefined) s.setMaxAnte(Math.max(1, Math.min(39, anteNumber(input.maxAnte))));
+ s.setSeed(seed);
+ if (s.applicationState.viewMode === "jaml") s.setViewMode("blueprint");
+ s.setStart(true);
+ s.setSelectedAnte(1);
+ const results = analyzeSeedFromStore(seed);
+ if (!results) throw new Error("Analysis failed for that seed");
+ return summarizeSeed(seed, results, 8);
+ },
+ },
+ {
+ name: "get_seed_overview",
+ description: "Per-ante overview of the loaded seed (or a given seed without loading it): boss, voucher, tags, first shop items and every booster pack with its contents.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ seed: { type: "string", description: "Defaults to the loaded seed." },
+ shopLimit: { type: "integer", minimum: 1, maximum: 100, description: "Shop items to include per ante (default 12)." },
+ },
+ additionalProperties: false,
+ },
+ execute: (input) => {
+ const { seed, results } = currentAnalysis(input.seed);
+ return summarizeSeed(seed, results, input.shopLimit ? anteNumber(input.shopLimit) : 12);
+ },
+ },
+ {
+ name: "get_ante_details",
+ description: "Everything Blueprint knows about one ante: full shop queue with indices, packs per blind with card indices, misc card sources (Riff-raff, Judgement, Wraith, The Soul...), tags, voucher, boss.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ ante: { type: "integer", minimum: 0, maximum: 39 },
+ seed: { type: "string", description: "Defaults to the loaded seed." },
+ },
+ required: ["ante"],
+ additionalProperties: false,
+ },
+ execute: (input) => {
+ const { results } = currentAnalysis(input.seed);
+ const ante = results.antes[anteNumber(input.ante)];
+ if (!ante) throw new Error(`Ante ${input.ante} is outside the generated range. Increase maxAnte with analyze_seed.`);
+ return describeAnte(ante);
+ },
+ },
+ {
+ name: "find_items",
+ description: "Search the loaded seed (or a given seed) for jokers, consumables, vouchers, tags or bosses by name (case-insensitive substring). Returns where each appears: ante, shop index, pack, or misc source. Legendary jokers (Perkeo, Triboulet, Yorick, Chicot, Canio) only resolve when showCardSpoilers is on (set_options); otherwise search for 'The Soul'.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ names: { type: "array", items: { type: "string" }, minItems: 1, description: "e.g. [\"Perkeo\", \"Diet Cola\", \"Negative Tag\", \"Ankh\"]" },
+ seed: { type: "string", description: "Defaults to the loaded seed." },
+ },
+ required: ["names"],
+ additionalProperties: false,
+ },
+ execute: (input) => {
+ const { seed, results } = currentAnalysis(input.seed);
+ const names = Array.isArray(input.names) ? input.names.map(String) : [String(input.names)];
+ return { seed, matches: findItems(results, names) };
+ },
+ },
+ {
+ name: "go_to_ante",
+ description: "Navigate the Blueprint view to an ante and optionally a blind so the user sees it.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ ante: { type: "integer", minimum: 0, maximum: 39 },
+ blind: { type: "string", enum: BLINDS },
+ },
+ required: ["ante"],
+ additionalProperties: false,
+ },
+ execute: (input) => {
+ const s = useCardStore.getState();
+ s.setSelectedAnte(anteNumber(input.ante));
+ if (typeof input.blind === "string" && BLINDS.includes(input.blind as Blinds)) s.setSelectedBlind(input.blind as Blinds);
+ return { selectedAnte: s.applicationState.selectedAnte, selectedBlind: useCardStore.getState().applicationState.selectedBlind };
+ },
+ },
+ {
+ name: "buy_item",
+ description: "Mark a shop item or pack card as purchased in the loaded seed. Purchases change later RNG exactly like the game, so re-read the overview afterwards. Use indices from get_ante_details.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ ante: { type: "integer", minimum: 0, maximum: 39 },
+ source: { type: "string", enum: ["shop", "pack"] },
+ index: { type: "integer", minimum: 0, description: "Shop queue index, or card index inside the pack." },
+ blind: { type: "string", enum: BLINDS, description: "Required for packs." },
+ packIndex: { type: "integer", minimum: 0, description: "Required for packs: pack position within the blind." },
+ },
+ required: ["ante", "source", "index"],
+ additionalProperties: false,
+ },
+ execute: (input) => {
+ const s = useCardStore.getState();
+ const { results } = currentAnalysis();
+ const anteNum = anteNumber(input.ante);
+ const ante = results.antes[anteNum];
+ if (!ante) throw new Error(`Ante ${anteNum} is not generated`);
+ const index = anteNumber(input.index);
+ const blind = (typeof input.blind === "string" && BLINDS.includes(input.blind as Blinds) ? input.blind : s.applicationState.selectedBlind) as Blinds;
+ let buy: BuyMetaData;
+ if (input.source === "shop") {
+ const card = ante.queue[index] as CardTuple | undefined;
+ if (!card) throw new Error(`No shop item at index ${index} in ante ${anteNum}`);
+ buy = new BuyMetaData({
+ location: LOCATIONS.SHOP,
+ locationType: LOCATION_TYPES.SHOP,
+ index,
+ ante: String(anteNum),
+ blind,
+ card,
+ name: card.name,
+ });
+ } else {
+ const packIndex = anteNumber(input.packIndex);
+ const pack = ante.blinds[blind].packs[packIndex];
+ const card = pack?.cards[index];
+ if (!pack || !card) throw new Error(`No pack card at pack ${packIndex}, card ${index} in ante ${anteNum} ${blind}`);
+ buy = new BuyMetaData({
+ location: pack.name,
+ locationType: LOCATION_TYPES.PACK,
+ index,
+ packIndex,
+ ante: String(anteNum),
+ blind,
+ card,
+ name: card.name,
+ });
+ }
+ s.addBuy(buy);
+ s.setSelectedAnte(anteNum);
+ s.setSelectedBlind(blind);
+ return { bought: buy.name, ante: anteNum, blind, source: input.source, index };
+ },
+ },
+ {
+ name: "set_options",
+ description: "Change analysis options. showCardSpoilers reveals what The Soul, Judgement and Wraith give (legendary jokers show as 'The Soul' until it is on). cardsPerAnte is how deep each shop queue is generated; maxMiscCardSource how many cards each misc source lists. The seed is re-analyzed.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ showCardSpoilers: { type: "boolean" },
+ cardsPerAnte: { type: "integer", minimum: 1, maximum: 1000 },
+ maxMiscCardSource: { type: "integer", minimum: 1, maximum: 100 },
+ },
+ additionalProperties: false,
+ },
+ execute: (input) => {
+ const s = useCardStore.getState();
+ if (typeof input.showCardSpoilers === "boolean") s.setShowCardSpoilers(input.showCardSpoilers);
+ if (input.cardsPerAnte !== undefined) s.setCardsPerAnte(Math.max(1, Math.min(1000, anteNumber(input.cardsPerAnte))));
+ if (input.maxMiscCardSource !== undefined) s.setMiscMaxSource(Math.max(1, Math.min(100, anteNumber(input.maxMiscCardSource))));
+ const next = useCardStore.getState();
+ return {
+ showCardSpoilers: next.applicationState.showCardSpoilers,
+ cardsPerAnte: next.engineState.cardsPerAnte,
+ maxMiscCardSource: next.applicationState.maxMiscCardSource,
+ };
+ },
+ },
+ {
+ name: "list_purchases",
+ description: "List every purchase the user has marked in the loaded seed.",
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
+ execute: () =>
+ Object.values(useCardStore.getState().shoppingState.buys).map((buy) => ({
+ name: buy.name,
+ ante: Number(buy.ante),
+ blind: buy.blind,
+ location: buy.location,
+ index: buy.index,
+ packIndex: buy.packIndex,
+ })),
+ },
+ {
+ name: "clear_purchases",
+ description: "Remove every marked purchase from the loaded seed.",
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
+ execute: () => {
+ useCardStore.getState().setBuys({});
+ return { cleared: true };
+ },
+ },
+ {
+ name: "load_seed_list",
+ description: "Load many seeds into the seed queue (one per line, whitespace or comma separated; CSV first column). The first seed is analyzed immediately; the user steps through the rest with the arrow keys.",
+ inputSchema: {
+ type: "object",
+ properties: { seeds: { type: "string", description: "Raw text containing the seeds." } },
+ required: ["seeds"],
+ additionalProperties: false,
+ },
+ execute: (input) => {
+ const seeds = parseSeedList(String(input.seeds ?? ""));
+ if (seeds.length === 0) throw new Error("No valid seeds found");
+ useCardStore.getState().setSeedQueue(seeds);
+ return { loaded: seeds.length, first: seeds[0] };
+ },
+ },
+ {
+ name: "step_seed_queue",
+ description: "Move through the loaded seed queue: delta of +1/-1 steps, or an absolute index. Loads and analyzes that seed.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ delta: { type: "integer" },
+ index: { type: "integer", minimum: 0 },
+ },
+ additionalProperties: false,
+ },
+ execute: (input) => {
+ const s = useCardStore.getState();
+ if (s.seedQueue.seeds.length === 0) throw new Error("Seed queue is empty. Call load_seed_list first.");
+ if (input.index !== undefined) s.jumpSeedQueue(anteNumber(input.index));
+ else s.stepSeedQueue(Math.trunc(Number(input.delta ?? 1)) || 1);
+ const q = useCardStore.getState().seedQueue;
+ return { index: q.index, count: q.seeds.length, seed: q.seeds[q.index] };
+ },
+ },
+ {
+ name: "set_view",
+ description: "Switch the main view: 'blueprint' (seed explorer) or 'jaml' (JAML seed search IDE).",
+ inputSchema: {
+ type: "object",
+ properties: { view: { type: "string", enum: ["blueprint", "jaml"] } },
+ required: ["view"],
+ additionalProperties: false,
+ },
+ execute: (input) => {
+ const view = input.view === "jaml" ? "jaml" : "blueprint";
+ useCardStore.getState().setViewMode(view);
+ return { view };
+ },
+ },
+ {
+ name: "remember",
+ description: "Save a short note about this user (favourite decks, strategies they like, seeds they are hunting). Notes persist in their browser and are shown to every future assistant session.",
+ inputSchema: {
+ type: "object",
+ properties: { note: { type: "string", minLength: 1, maxLength: 300 } },
+ required: ["note"],
+ additionalProperties: false,
+ },
+ execute: (input) => ({ notes: remember(String(input.note ?? "")) }),
+ },
+];