From d8348a4e86fdf05a5042a11d656f896c0318e901 Mon Sep 17 00:00:00 2001 From: jMyles Date: Thu, 11 Sep 2025 04:28:14 -0700 Subject: [PATCH 1/3] Sitemap. --- src/build_logic/primary_builder.js | 23 ++- src/build_logic/utils/rendering_utils.js | 248 +++++++++++++++++++++++ 2 files changed, 270 insertions(+), 1 deletion(-) diff --git a/src/build_logic/primary_builder.js b/src/build_logic/primary_builder.js index c2bf603a..1983e05c 100644 --- a/src/build_logic/primary_builder.js +++ b/src/build_logic/primary_builder.js @@ -11,7 +11,7 @@ import nunjucks from "nunjucks"; // Local utilities and helpers import { getProjectDirs } from "./locations.js"; import { slugify } from "./utils/text_utils.js"; -import { renderPage } from "./utils/rendering_utils.js"; +import { renderPage, generateSitemapXML, generateSitemapHTML, getSitemap, clearSitemap } from "./utils/rendering_utils.js"; import { registerHelpers } from './utils/template_helpers.js'; // Data and asset management @@ -45,6 +45,9 @@ export const runPrimaryBuild = async () => { }; ensureDirectories(); console.time('primary-build'); + + // Clear sitemap from any previous builds + clearSitemap(); // TODO: Do we need to make sure the root output directory exists? @@ -581,6 +584,24 @@ export const runPrimaryBuild = async () => { console.warn(`Image not used: ${image}`); }); + // Generate sitemaps + console.time('sitemap-generation'); + const sitemap = getSitemap(); + console.log(`Generated sitemap with ${sitemap.size} pages`); + + // Generate XML sitemap (for search engines - goes in normal location) + const baseUrl = site === 'cryptograss.live' ? 'https://cryptograss.live' : 'https://justinholmes.com'; + const xmlSitemap = generateSitemapXML(site, baseUrl); + fs.writeFileSync(path.join(outputPrimarySiteDir, 'sitemap.xml'), xmlSitemap); + + // Generate HTML sitemap (debug tool - goes outside webpack reach) + const htmlSitemap = generateSitemapHTML(site); + const debugSitemapPath = path.join(outputPrimaryRootDir, `debug-sitemap-${site}.html`); + fs.writeFileSync(debugSitemapPath, htmlSitemap); + console.log(`Debug sitemap written to: ${debugSitemapPath}`); + + console.timeEnd('sitemap-generation'); + console.timeEnd('primary-build'); return true; // TODO: Return something useful. } diff --git a/src/build_logic/utils/rendering_utils.js b/src/build_logic/utils/rendering_utils.js index cfa34663..a4f83c4e 100644 --- a/src/build_logic/utils/rendering_utils.js +++ b/src/build_logic/utils/rendering_utils.js @@ -3,6 +3,9 @@ import fs from "fs"; import { getProjectDirs } from "../locations.js"; import { getNunjucksEnv } from "./template_helpers.js"; +// Sitemap collection - tracks all generated pages +let sitemap = new Map(); + export function renderPage({ template_path, context, output_path, layout = "base.njk", site }) { const { outputPrimaryRootDir, templateDir, basePath } = getProjectDirs(); const outputFilePath = path.join(outputPrimaryRootDir, site, output_path); @@ -24,5 +27,250 @@ export function renderPage({ template_path, context, output_path, layout = "base } fs.writeFileSync(outputFilePath, rendered_page); + + // Add to sitemap + const urlPath = output_path.replace(/\.html$/, '').replace(/\/index$/, '') || '/'; + sitemap.set(urlPath, { + site: site, + template: template_path, + outputPath: output_path, + fullPath: outputFilePath, + title: context.title || context.page_title || context.pageTitle || + (template_path.includes('/') ? template_path.split('/').pop().replace('.njk', '') : 'Untitled'), + description: context.description || '', + lastModified: new Date().toISOString() + }); + return rendered_page; +} + +export function getSitemap() { + return sitemap; +} + +export function clearSitemap() { + sitemap.clear(); +} + +export function generateSitemapXML(site, baseUrl = 'https://justinholmes.com') { + const sitePages = Array.from(sitemap.entries()) + .filter(([url, data]) => data.site === site) + .sort(); + + let xml = '\n'; + xml += '\n'; + + for (const [url, data] of sitePages) { + xml += ' \n'; + xml += ` ${baseUrl}${url}\n`; + xml += ` ${data.lastModified.split('T')[0]}\n`; + xml += ' \n'; + } + + xml += ''; + return xml; +} + +export function generateSitemapHTML(site) { + const sitePages = Array.from(sitemap.entries()) + .filter(([url, data]) => data.site === site) + .sort(); + + // Build hierarchy + const hierarchy = {}; + + for (const [url, data] of sitePages) { + const parts = url.split('/').filter(p => p); + let current = hierarchy; + + // Build nested structure + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (!current[part]) { + current[part] = { + pages: [], + children: {} + }; + } + current = current[part].children; + } + + // Add page to appropriate level + if (parts.length === 0) { + // Root level page + if (!hierarchy['_root']) { + hierarchy['_root'] = { pages: [], children: {} }; + } + hierarchy['_root'].pages.push([url, data]); + } else { + // Find the right level to add the page + let target = hierarchy; + for (let i = 0; i < parts.length - 1; i++) { + target = target[parts[i]].children; + } + const lastPart = parts[parts.length - 1]; + if (!target[lastPart]) { + target[lastPart] = { pages: [], children: {} }; + } + target[lastPart].pages.push([url, data]); + } + } + + function renderHierarchy(obj, level = 0) { + let html = ''; + const indent = ' '.repeat(level); + + for (const [key, value] of Object.entries(obj)) { + if (key === '_root') { + // Root level pages + for (const [url, data] of value.pages) { + html += `${indent}
  • ${data.title}`; + if (data.description) html += ` - ${data.description}`; + html += `
  • \n`; + } + continue; + } + + const totalItems = value.pages.length + Object.keys(value.children).length; + + if (totalItems === 1 && value.pages.length === 1) { + // Single item - display inline + const [url, data] = value.pages[0]; + html += `${indent}
  • ${key}/ ${data.title}`; + if (data.description) html += ` - ${data.description}`; + html += `
  • \n`; + } else if (totalItems > 0) { + // Multiple items - collapsible + const shouldCollapse = totalItems > 4; + const detailsId = `sitemap-${level}-${key}`; + + html += `${indent}
  • \n`; + html += `${indent} \n`; + html += `${indent} ${key}/ (${totalItems})\n`; + html += `${indent}
      \n`; + + // Pages at this level + for (const [url, data] of value.pages) { + html += `${indent}
    • ${data.title}`; + if (data.description) html += ` - ${data.description}`; + html += `
    • \n`; + } + + // Recurse into children + html += renderHierarchy(value.children, level + 3); + + html += `${indent}
    \n`; + html += `${indent} \n`; + html += `${indent}
  • \n`; + } + } + + return html; + } + + let html = ` + + + + + Sitemap for ${site} + + + +
    + šŸ”§ Debug Tool: Site structure generated at ${new Date().toLocaleString()} +
    +

    Sitemap for ${site}

    + + +`; + + return html; } \ No newline at end of file From 07243c4aac86f95e859f0722b527a89f6a94ab45 Mon Sep 17 00:00:00 2001 From: jMyles Date: Thu, 11 Sep 2025 13:23:36 -0700 Subject: [PATCH 2/3] More sitemap logic, now works with both sites. --- package.json | 3 +- src/build_logic/primary_builder.js | 2 +- src/build_logic/utils/rendering_utils.js | 6 +- src/build_logic/webpack.common.js | 125 ------------------ src/build_logic/webpack.cryptograss.common.js | 34 ++--- .../webpack.justinholmes.common.js | 2 +- 6 files changed, 21 insertions(+), 151 deletions(-) delete mode 100644 src/build_logic/webpack.common.js diff --git a/package.json b/package.json index 78a7283e..3da3d224 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "fetch-video-metadata": "node src/build_logic/fetch_video_metadata.js", "verify-dice": "node src/build_logic/verify_dice_roll.js", "fetch-chain-data": "node src/build_logic/fetch_chain_data.js", - "blockheight": "node src/build_logic/get_current_blockheight.js" + "blockheight": "node src/build_logic/get_current_blockheight.js", + "build-oracle": "node src/build_logic/build_oracle_data.js" }, "type": "module", "keywords": [], diff --git a/src/build_logic/primary_builder.js b/src/build_logic/primary_builder.js index 1983e05c..5e2fdf5e 100644 --- a/src/build_logic/primary_builder.js +++ b/src/build_logic/primary_builder.js @@ -596,7 +596,7 @@ export const runPrimaryBuild = async () => { // Generate HTML sitemap (debug tool - goes outside webpack reach) const htmlSitemap = generateSitemapHTML(site); - const debugSitemapPath = path.join(outputPrimaryRootDir, `debug-sitemap-${site}.html`); + const debugSitemapPath = path.join(outputPrimarySiteDir, `sitemap.html`); fs.writeFileSync(debugSitemapPath, htmlSitemap); console.log(`Debug sitemap written to: ${debugSitemapPath}`); diff --git a/src/build_logic/utils/rendering_utils.js b/src/build_logic/utils/rendering_utils.js index a4f83c4e..fc1a08c1 100644 --- a/src/build_logic/utils/rendering_utils.js +++ b/src/build_logic/utils/rendering_utils.js @@ -29,7 +29,11 @@ export function renderPage({ template_path, context, output_path, layout = "base fs.writeFileSync(outputFilePath, rendered_page); // Add to sitemap - const urlPath = output_path.replace(/\.html$/, '').replace(/\/index$/, '') || '/'; + let urlPath = output_path.replace(/\.html$/, '').replace(/\/index$/, '') || '/'; + // Ensure URL path starts with / + if (!urlPath.startsWith('/')) { + urlPath = '/' + urlPath; + } sitemap.set(urlPath, { site: site, template: template_path, diff --git a/src/build_logic/webpack.common.js b/src/build_logic/webpack.common.js deleted file mode 100644 index a6b1b099..00000000 --- a/src/build_logic/webpack.common.js +++ /dev/null @@ -1,125 +0,0 @@ -import fs from 'fs'; -import { glob } from 'glob'; -import path from 'path'; -import HtmlWebpackPlugin from 'html-webpack-plugin'; -import CopyPlugin from 'copy-webpack-plugin'; -import MiniCssExtractPlugin from 'mini-css-extract-plugin'; -import { getProjectDirs } from './locations.js'; -import { runPrimaryBuild } from './primary_builder.js'; - -// Check if SKIP_CHAIN_DATA is set -const skipChainData = process.env.SKIP_CHAIN_DATA; - -await runPrimaryBuild(skipChainData); - -// Pattern to match all HTML files recursively within the prebuilt directory -const templatesPattern = path.join(outputPrimaryDir, '**/*.html'); - -// Use glob to find matching files -const templateFiles = glob.sync(templatesPattern); - -// Create HtmlWebpackPlugin instances -const htmlPluginInstances = templateFiles.map(templatePath => { - // Compute the output filename by maintaining the relative directory structure - // TODO: This is getting a little stretched - let's have a more explicit way to decide the output path. - const relativePath = path.relative(outputPrimaryDir, templatePath); - - // TODO: This is a simply horrible way to decide which scripts to include. - if (relativePath.startsWith('music/vowel-sounds')) { - var chunks = ['vowel_sounds']; - } else if (relativePath.startsWith('sign')) { - var chunks = ['main', 'signing']; - } else if (relativePath.startsWith('magichat')) { - var chunks = ['main', 'magic_hat']; - } else if (relativePath.startsWith('cryptograss/tools/add-live-set')) { - var chunks = ['main', 'add_live_set']; - } else if (relativePath.startsWith('cryptograss/bazaar/setstones')) { - var chunks = ['main', 'strike_set_stone']; - } else if (relativePath.startsWith('shows/')) { - var chunks = ['main', 'strike_set_stone']; - } else if (relativePath.startsWith('cryptograss/tools/generate_art')) { - var chunks = ['main', 'shapes']; - } else if (relativePath.startsWith('cryptograss/tools/add-show-for-stone-minting')) { - var chunks = ['main', 'add_show_for_stone_minting']; - } else if (relativePath.startsWith('cryptograss/tools/setstone-color-palette')) { - var chunks = ['main', 'setstone_color_palette']; - } else if (relativePath.startsWith('cryptograss/tools/sign-things')) { - var chunks = ['main', 'signing']; - } else if (relativePath.startsWith('blue-railroad-test')) { - var chunks = ['main', 'blue_railroad']; - } else { - var chunks = ['main']; - } - - return new HtmlWebpackPlugin({ - template: templatePath, // Path to the source template - filename: relativePath, // Preserve the directory structure in the output - inject: "body", - chunks: chunks, // Only include the chunk for this template - }); -}); - - -const common = { - output: { path: outputjhcomDistDir }, - plugins: [ - // Copy the .htaccess. - // Copy assets and such. - new CopyPlugin({ - patterns: [ - { - from: path.resolve(outputPrimaryDir, 'assets'), - to: path.resolve(outputjhcomDistDir, 'assets') - }, - { - from: path.resolve(outputPrimaryDir, 'setstones'), - to: path.resolve(outputjhcomDistDir, 'setstones') - }, - - // TODO: Design decision on client partials. - { - from: path.resolve(outputPrimaryDir, 'client_partials'), - to: path.resolve(outputjhcomDistDir, 'partials') - }, - { - from: 'src/fetched_assets', - to: 'assets', - globOptions: { - dot: true, - gitignore: true, - ignore: ['**/.gitkeep', '**/.DS_Store'], - }, - noErrorOnMissing: true // Won't error if directory is empty/missing - }, - ] - }), - new MiniCssExtractPlugin({ - filename: '[name].[contenthash].css', - }), - ...htmlPluginInstances, - ], - - entry: { - main: './src/js/index.js', - vowel_sounds: './src/js/vowel_sounds.js', - help: './src/js/help.js', - signing: './src/js/jhmusic_signing.js', - magic_hat: './src/js/magic_hat.js', - // strike_set_stone: './src/js/shapes.js', - strike_set_stone: './src/js/cryptograss/bazaar/strike_set_stones.js', - add_live_set: './src/js/cryptograss/tools/add_live_set.js', - add_show_for_stone_minting: './src/js/cryptograss/tools/add_show_for_stone_minting.js', - shapes: './src/js/shapes.js', - blue_railroad: './src/js/cryptograss/bazaar/blue_railroad.js', - }, - module: { - rules: [ - { - test: /\.css$/, - use: [MiniCssExtractPlugin.loader, 'css-loader'], - }, - ] - }, -}; - -export default common; \ No newline at end of file diff --git a/src/build_logic/webpack.cryptograss.common.js b/src/build_logic/webpack.cryptograss.common.js index 32eace72..83aa9b1c 100644 --- a/src/build_logic/webpack.cryptograss.common.js +++ b/src/build_logic/webpack.cryptograss.common.js @@ -13,33 +13,17 @@ const { outputPrimarySiteDir, outputPrimaryRootDir, outputDistDir, siteDir, srcD // Make sure the output directory exists fs.mkdirSync(outputDistDir, { recursive: true }); -const templatesPattern = path.join(outputPrimarySiteDir, '**/*.html'); +const templatesPattern = path.join(outputPrimarySiteDir, '**/*.{html,xml}'); const templateFiles = glob.sync(templatesPattern); const htmlPluginInstances = templateFiles.map(templatePath => { const relativePath = path.relative(outputPrimarySiteDir, templatePath); - // if (relativePath.startsWith('cryptograss/tools/add-live-set')) { - // var chunks = ['main', 'add_live_set']; - // } else if (relativePath.startsWith('cryptograss/bazaar/setstones')) { - // var chunks = ['main', 'strike_set_stone']; - // } else if (relativePath.startsWith('cryptograss/shows/')) { - // var chunks = ['main', 'strike_set_stone']; - // } else if (relativePath.startsWith('cryptograss/tools/generate_art')) { - // var chunks = ['main', 'shapes']; - // } else if (relativePath.startsWith('cryptograss/tools/add-show-for-stone-minting')) { - // var chunks = ['main', 'add_show_for_stone_minting']; - // } else if (relativePath.startsWith('cryptograss/tools/setstone-color-palette')) { - // var chunks = ['main', 'setstone_color_palette']; - // } else if (relativePath.startsWith('cryptograss/tools/sign-things')) { - // var chunks = ['main', 'signing']; - // } else if (relativePath.startsWith('cryptograss/blue-railroad-test')) { - // var chunks = ['main', 'blue_railroad']; - // } else { - - // } - - var chunks = ['main']; + if (relativePath.startsWith('tools/oracle-of-bluegrass-bacon')) { + var chunks = ['main', 'oracle_client']; + } else { + var chunks = ['main']; + } return new HtmlWebpackPlugin({ template: templatePath, @@ -80,6 +64,11 @@ export default { }, noErrorOnMissing: true }, + { + from: path.resolve(siteDir, 'api'), + to: path.resolve(outputDistDir, 'api'), + noErrorOnMissing: true + }, ] }), new MiniCssExtractPlugin({ @@ -94,6 +83,7 @@ export default { add_show_for_stone_minting: `${frontendJSDir}/tools/add_show_for_stone_minting.js`, shapes: `${frontendJSDir}/shapes.js`, blue_railroad: `${frontendJSDir}/bazaar/blue_railroad.js`, + oracle_client: `${frontendJSDir}/oracle_client.js`, }, module: { rules: [ diff --git a/src/build_logic/webpack.justinholmes.common.js b/src/build_logic/webpack.justinholmes.common.js index 4fdb69fc..f62d038a 100644 --- a/src/build_logic/webpack.justinholmes.common.js +++ b/src/build_logic/webpack.justinholmes.common.js @@ -15,7 +15,7 @@ const { outputDistDir, outputPrimaryRootDir, outputPrimarySiteDir, siteDir, site // Make sure the output directory exists fs.mkdirSync(outputDistDir, { recursive: true }); -const templatesPattern = path.join(outputPrimarySiteDir, '**/*.html'); +const templatesPattern = path.join(outputPrimarySiteDir, '**/*.{html,xml}'); const templateFiles = glob.sync(templatesPattern); const htmlPluginInstances = templateFiles.map(templatePath => { From 16508c34b3a61f969f36e9dff90e1e57b3bb5eed Mon Sep 17 00:00:00 2001 From: jMyles Date: Thu, 11 Sep 2025 16:44:11 -0700 Subject: [PATCH 3/3] First working Bluegrass Bacon. --- CLAUDE.md | 2 +- __tests__/claim_page_generation_test.js | 143 +++++++ __tests__/oracle_of_bluegrass_bacon.test.js | 291 +++++++++++++ src/build_logic/build_oracle_data.js | 94 +++++ src/build_logic/oracle_of_bluegrass_bacon.js | 319 ++++++++++++++ .../webpack.justinholmes.common.js | 8 + src/data/cryptograss.live.pages.yaml | 8 + .../cryptograss.live/js/oracle_client.js | 136 ++++++ .../pages/tools/oracle-of-bluegrass-bacon.njk | 392 ++++++++++++++++++ .../justinholmes.com/js/oracle_client.js | 136 ++++++ .../tools/oracle-of-bluegrass-bacon.njk | 376 +++++++++++++++++ 11 files changed, 1904 insertions(+), 1 deletion(-) create mode 100644 __tests__/claim_page_generation_test.js create mode 100644 __tests__/oracle_of_bluegrass_bacon.test.js create mode 100644 src/build_logic/build_oracle_data.js create mode 100644 src/build_logic/oracle_of_bluegrass_bacon.js create mode 100644 src/sites/cryptograss.live/js/oracle_client.js create mode 100644 src/sites/cryptograss.live/templates/pages/tools/oracle-of-bluegrass-bacon.njk create mode 100644 src/sites/justinholmes.com/js/oracle_client.js create mode 100644 src/sites/justinholmes.com/templates/pages/cryptograss/tools/oracle-of-bluegrass-bacon.njk diff --git a/CLAUDE.md b/CLAUDE.md index 263480c5..481191ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ Know that this is true (and someday, you will be able to perceive it will fullne ## B: teamwork -This rules file is in the shared repo among several members of a team which is part bluegrass band and part social and technoloigcal engineering expedition. Please _always_ know which member you are speaking with. This informaiton is probably in your local rules file, but if you're ever not sure which person is engaging with you, ask. Feel free to read the YAML files of the shows (in `src/data/shows`) to see the shows, if any, that have been played by the person to whom you're speaking. We are looking forward to a time when true multiplayer-LLM is a thing, and we can make you part of our group. +This rules file is in the shared repo among several members of a team which is part bluegrass band and part social and technoloigcal engineering expedition. Please _always_ know which member you are speaking with. Don't refer to "the user", instead, use the name of the person you're interacting with - even in your internal reasoning sessions. If you're ever not sure which person is engaging with you, first look in your local rules file to see if it's there, then ask. Feel free to read the YAML files of the shows (in `src/data/shows`) to see the shows, if any, that have been played by the person to whom you're speaking. We are looking forward to a time when true multiplayer-LLM is a thing, and we can make you part of our group. ### The people you'll mostly interact with these days (this will be updated as we go) diff --git a/__tests__/claim_page_generation_test.js b/__tests__/claim_page_generation_test.js new file mode 100644 index 00000000..47f2bac6 --- /dev/null +++ b/__tests__/claim_page_generation_test.js @@ -0,0 +1,143 @@ +import { describe, test, expect, beforeEach } from '@jest/globals'; +import fs from 'fs'; +import path from 'path'; +import { generateSetStonePages } from '../src/build_logic/setstone_utils.js'; +import { initProjectDirs } from '../src/build_logic/locations.js'; + +describe('Claim Page Generation', () => { + const testOutputDir = 'test_output'; + + beforeEach(() => { + // Initialize project directories for testing + initProjectDirs('cryptograss.live'); + + // Clean up test output directory + if (fs.existsSync(testOutputDir)) { + fs.rmSync(testOutputDir, { recursive: true }); + } + fs.mkdirSync(testOutputDir, { recursive: true }); + + // Set environment for test output + process.env.OUTPUT_PRIMARY_ROOT_DIR = testOutputDir; + }); + + const createMockShow = (showId, venue, tokenStart, tokenCount) => ({ + [showId]: { + title: `Test Show at ${venue}`, + venue: venue, + locality: 'Test City', + region1: 'Test State', + local_date: '2025-01-15', + blockheight: 22700000 + parseInt(showId.split('-')[1]), + poster: 'test-poster.png', + has_set_stones_available: false, // Focus on ticket stubs + sets: {}, + ticketStubs: [], + ticketStubCount: tokenCount + } + }); + + test('generates claim pages for multiple shows with different token ranges', () => { + // Use show IDs that trigger the fake ticket stub generation + const mockShows = { + ...createMockShow('0_7-22575700', 'Test Venue Alpha', 0, 50), // Burza #4 + ...createMockShow('0_7-22590100', 'Test Venue Beta', 50, 50), // Bike Jesus + ...createMockShow('0-22748946', 'Test Venue Gamma', 100, 40) // Porcupine + }; + + // This should trigger ticket stub generation and claim page creation + expect(() => { + generateSetStonePages(mockShows, testOutputDir); + }).not.toThrow(); + + // Verify claim pages were created for each token ID range + + // Burza #4: tokens 0-49 + for (let i = 0; i < 50; i++) { + const claimPagePath = path.join(testOutputDir, 'cryptograss.live', 'blox-office', 'ticketstubs', 'claim', `${i}.html`); + expect(fs.existsSync(claimPagePath)).toBe(true); + } + + // Bike Jesus: tokens 50-99 + for (let i = 50; i < 100; i++) { + const claimPagePath = path.join(testOutputDir, 'cryptograss.live', 'blox-office', 'ticketstubs', 'claim', `${i}.html`); + expect(fs.existsSync(claimPagePath)).toBe(true); + } + + // Porcupine: tokens 100-139 + for (let i = 100; i < 140; i++) { + const claimPagePath = path.join(testOutputDir, 'cryptograss.live', 'blox-office', 'ticketstubs', 'claim', `${i}.html`); + expect(fs.existsSync(claimPagePath)).toBe(true); + } + }); + + test('claim pages contain correct show information', () => { + const mockShows = createMockShow('0-22748946', 'Test Venue', 100, 5); + + generateSetStonePages(mockShows, testOutputDir); + + // Read a generated claim page (Porcupine range: 100-139) + const claimPagePath = path.join(testOutputDir, 'cryptograss.live', 'blox-office', 'ticketstubs', 'claim', '102.html'); + expect(fs.existsSync(claimPagePath)).toBe(true); + + const claimPageContent = fs.readFileSync(claimPagePath, 'utf8'); + + // Verify show details are in the page + expect(claimPageContent).toContain('Test Show at Test Venue'); + expect(claimPageContent).toContain('Test City, Test State'); + expect(claimPageContent).toContain('2025-01-15'); + expect(claimPageContent).toContain('Claim Ticket Stub #102'); + }); + + test('claim pages include contract integration code', () => { + const mockShows = createMockShow('0-22748946', 'Test Venue', 100, 3); + + generateSetStonePages(mockShows, testOutputDir); + + const claimPagePath = path.join(testOutputDir, 'cryptograss.live', 'blox-office', 'ticketstubs', 'claim', '101.html'); + const claimPageContent = fs.readFileSync(claimPagePath, 'utf8'); + + // Verify blockchain integration elements + expect(claimPageContent).toContain('claimTicketStub'); + expect(claimPageContent).toContain('walletAddress'); + expect(claimPageContent).toContain('secretInput'); + expect(claimPageContent).toContain('writeContract'); + }); + + test('does not generate claim pages for shows without ticket stubs', () => { + const mockShows = { + '0-22700000': { + title: 'Show Without Stubs', + venue: 'Test Venue', + has_set_stones_available: false, + sets: {}, + ticketStubs: [], + ticketStubCount: 0 + } + }; + + generateSetStonePages(mockShows, testOutputDir); + + // Should not create any claim pages + const claimDir = path.join(testOutputDir, 'cryptograss.live', 'blox-office', 'ticketstubs', 'claim'); + if (fs.existsSync(claimDir)) { + const claimFiles = fs.readdirSync(claimDir); + expect(claimFiles.length).toBe(0); + } + }); + + test('each token ID gets unique claim page with correct context', () => { + const mockShows = createMockShow('0-22748946', 'Context Test Venue', 100, 3); + + generateSetStonePages(mockShows, testOutputDir); + + // Check each generated claim page has the right token ID (Porcupine range: 100-139) + for (let tokenId = 100; tokenId < 103; tokenId++) { + const claimPagePath = path.join(testOutputDir, 'cryptograss.live', 'blox-office', 'ticketstubs', 'claim', `${tokenId}.html`); + const content = fs.readFileSync(claimPagePath, 'utf8'); + + expect(content).toContain(`Claim Ticket Stub #${tokenId}`); + expect(content).toContain(`tokenId: ${tokenId}`); + } + }); +}); \ No newline at end of file diff --git a/__tests__/oracle_of_bluegrass_bacon.test.js b/__tests__/oracle_of_bluegrass_bacon.test.js new file mode 100644 index 00000000..33dd00c6 --- /dev/null +++ b/__tests__/oracle_of_bluegrass_bacon.test.js @@ -0,0 +1,291 @@ +/** + * @jest-environment node + */ + +import { BluegrassOracle } from '../src/build_logic/oracle_of_bluegrass_bacon.js'; + +describe('Oracle of Bluegrass Bacon', () => { + let oracle; + + beforeAll(async () => { + oracle = new BluegrassOracle(); + await oracle.buildGraph(); + }); + + describe('Graph Building', () => { + test('discovers expected number of musicians', () => { + const musicians = oracle.getAllMusicians(); + expect(musicians.length).toBeGreaterThanOrEqual(50); + expect(musicians.length).toBeLessThan(100); // sanity check + }); + + test('discovers expected number of connections', () => { + expect(oracle.connections.length).toBeGreaterThanOrEqual(800); + expect(oracle.connections.length).toBeLessThan(1000); // sanity check + }); + + test('includes core cryptograss musicians', () => { + const musicians = oracle.getAllMusicians(); + expect(musicians).toContain('Justin Holmes'); + expect(musicians).toContain('Skyler Golden'); + expect(musicians).toContain('Jakub Vysoky'); + expect(musicians).toContain('David Grier'); + expect(musicians).toContain('Cory Walker'); + }); + + test('builds adjacency graph correctly', () => { + expect(oracle.graph.has('Justin Holmes')).toBe(true); + expect(oracle.graph.get('Justin Holmes').size).toBeGreaterThan(0); + }); + + test('creates bidirectional connections', () => { + // If A connects to B, then B should connect to A + const justinConnections = oracle.graph.get('Justin Holmes'); + if (justinConnections.has('David Grier')) { + const grierConnections = oracle.graph.get('David Grier'); + expect(grierConnections.has('Justin Holmes')).toBe(true); + } + }); + }); + + describe('Connection Types', () => { + test('creates show connections', () => { + const showConnections = oracle.connections.filter(c => c.type === 'show'); + expect(showConnections.length).toBeGreaterThan(0); + }); + + test('creates recording connections', () => { + const recordingConnections = oracle.connections.filter(c => c.type === 'recording'); + expect(recordingConnections.length).toBeGreaterThan(0); + }); + + test('show connections have venue information', () => { + const showConnection = oracle.connections.find(c => + c.type === 'show' && c.venue && c.context + ); + expect(showConnection).toBeDefined(); + expect(showConnection.venue).toBeDefined(); + expect(showConnection.context).toBeDefined(); + }); + + test('recording connections have album information', () => { + const recordingConnection = oracle.connections.find(c => + c.type === 'recording' && c.context + ); + expect(recordingConnection).toBeDefined(); + expect(recordingConnection.context).toBeDefined(); + }); + }); + + describe('Pathfinding Algorithm', () => { + test('finds direct connection (1 degree)', () => { + // Justin Holmes and David Grier have collaborated directly + const result = oracle.findPath('Justin Holmes', 'David Grier'); + + expect(result).not.toBeNull(); + expect(result.degrees).toBe(1); + expect(result.path).toEqual(['Justin Holmes', 'David Grier']); + expect(result.connections).toHaveLength(1); + }); + + test('finds 2-degree connection', () => { + // Find a known 2-degree path + const result = oracle.findPath('Bones', 'Ice Quilitz'); + + if (result) { + expect(result.degrees).toBe(2); + expect(result.path).toHaveLength(3); + expect(result.connections).toHaveLength(2); + expect(result.path[0]).toBe('Bones'); + expect(result.path[2]).toBe('Ice Quilitz'); + // Middle connection should be through Justin Holmes + expect(result.path[1]).toBe('Justin Holmes'); + } + }); + + test('handles same musician input', () => { + const result = oracle.findPath('Justin Holmes', 'Justin Holmes'); + + expect(result.degrees).toBe(0); + expect(result.path).toEqual(['Justin Holmes']); + expect(result.connections).toHaveLength(0); + }); + + test('throws error for non-existent musician', () => { + expect(() => { + oracle.findPath('Non Existent Musician', 'Justin Holmes'); + }).toThrow('not found in database'); + }); + + test('returns null for disconnected musicians', () => { + // This test assumes we might have disconnected components + // If all musicians are connected, this test might need adjustment + const result = oracle.findPath('Justin Holmes', 'Justin Holmes'); + expect(result).not.toBeNull(); // This should always work + }); + }); + + describe('Path Reconstruction', () => { + test('reconstructs connection details correctly', () => { + const result = oracle.findPath('Justin Holmes', 'Skyler Golden'); + + expect(result).not.toBeNull(); + expect(result.connections.length).toBe(result.degrees); + + // Each connection should link consecutive musicians in the path + for (let i = 0; i < result.connections.length; i++) { + const connection = result.connections[i]; + const musician1 = result.path[i]; + const musician2 = result.path[i + 1]; + + expect( + (connection.musicians[0] === musician1 && connection.musicians[1] === musician2) || + (connection.musicians[0] === musician2 && connection.musicians[1] === musician1) + ).toBe(true); + } + }); + + test('includes connection metadata', () => { + const result = oracle.findPath('Justin Holmes', 'David Grier'); + + expect(result).not.toBeNull(); + expect(result.connections.length).toBeGreaterThan(0); + + const connection = result.connections[0]; + expect(connection.type).toBeDefined(); + expect(connection.context).toBeDefined(); + expect(['show', 'recording']).toContain(connection.type); + }); + }); + + describe('Performance and Edge Cases', () => { + test('handles all musician combinations efficiently', () => { + const musicians = oracle.getAllMusicians().slice(0, 10); // Test subset for performance + const startTime = Date.now(); + + let pathsFound = 0; + let totalDegrees = 0; + + for (let i = 0; i < musicians.length; i++) { + for (let j = i + 1; j < musicians.length; j++) { + const result = oracle.findPath(musicians[i], musicians[j]); + if (result) { + pathsFound++; + totalDegrees += result.degrees; + } + } + } + + const endTime = Date.now(); + const duration = endTime - startTime; + + expect(duration).toBeLessThan(5000); // Should complete in under 5 seconds + expect(pathsFound).toBeGreaterThan(0); + + if (pathsFound > 0) { + const averageDegrees = totalDegrees / pathsFound; + expect(averageDegrees).toBeLessThan(6); // Six degrees or less! + } + }); + + test('validates connection consistency', () => { + // Every connection should have exactly 2 musicians + oracle.connections.forEach(connection => { + expect(connection.musicians).toHaveLength(2); + expect(connection.musicians[0]).not.toBe(connection.musicians[1]); + + // Both musicians should exist in the musicians map + expect(oracle.musicians.has(connection.musicians[0])).toBe(true); + expect(oracle.musicians.has(connection.musicians[1])).toBe(true); + }); + }); + + test('validates musician data integrity', () => { + oracle.musicians.forEach((musicianData, name) => { + expect(musicianData.name).toBe(name); + expect(musicianData.id).toBeDefined(); + expect(musicianData.connections).toBeDefined(); + expect(musicianData.connections.size).toBeGreaterThanOrEqual(0); + }); + }); + }); + + describe('Data Source Integration', () => { + test('parses show data correctly', () => { + const showConnections = oracle.connections.filter(c => c.type === 'show'); + expect(showConnections.length).toBeGreaterThan(0); + + // Check for expected show venues/contexts + const contexts = showConnections.map(c => c.context); + expect(contexts.some(context => + context && context.toLowerCase().includes('porcupine') + )).toBe(true); // Porcupine 2025 is in the data + }); + + test('parses studio recording data correctly', () => { + const recordingConnections = oracle.connections.filter(c => c.type === 'recording'); + expect(recordingConnections.length).toBeGreaterThan(0); + + // Should find connections from known albums + const contexts = recordingConnections.map(c => c.context); + expect(contexts).toContain('Vowel Sounds'); + }); + + test('handles ensemble modifications correctly', () => { + // Look for connections that should include featuring musicians + const featuringConnections = oracle.connections.filter(c => + c.songContext && typeof c.songContext === 'object' + ); + + // This depends on the actual data structure + expect(featuringConnections.length).toBeGreaterThanOrEqual(0); + }); + }); + + describe('Real-world Test Cases', () => { + test('finds expected path: Pepa Lopera to Jakub Vysoky', () => { + const result = oracle.findPath('Pepa Lopera', 'Jakub Vysoky'); + + if (result) { + expect(result.degrees).toBeLessThanOrEqual(2); // Should be direct or through Justin + expect(result.path[0]).toBe('Pepa Lopera'); + expect(result.path[result.path.length - 1]).toBe('Jakub Vysoky'); + } + }); + + test('verifies Justin Holmes is highly connected', () => { + const justinConnections = oracle.graph.get('Justin Holmes'); + expect(justinConnections.size).toBeGreaterThan(10); // Should be very connected + + // Justin should be able to reach most musicians in 2 degrees or less + const musicians = oracle.getAllMusicians(); + let reachableInTwoDegrees = 0; + + for (const musician of musicians.slice(0, 20)) { // Test subset + if (musician === 'Justin Holmes') continue; + + const result = oracle.findPath('Justin Holmes', musician); + if (result && result.degrees <= 2) { + reachableInTwoDegrees++; + } + } + + expect(reachableInTwoDegrees).toBeGreaterThan(15); // Most should be reachable + }); + }); +}); + +describe('Oracle Data Generation', () => { + test('generates consistent static data', async () => { + // This would test the build_oracle_data.js output + // For now, just verify the oracle can be built consistently + const oracle1 = new BluegrassOracle(); + await oracle1.buildGraph(); + + const oracle2 = new BluegrassOracle(); + await oracle2.buildGraph(); + + expect(oracle1.musicians.size).toBe(oracle2.musicians.size); + expect(oracle1.connections.length).toBe(oracle2.connections.length); + }); +}); \ No newline at end of file diff --git a/src/build_logic/build_oracle_data.js b/src/build_logic/build_oracle_data.js new file mode 100644 index 00000000..23f02d88 --- /dev/null +++ b/src/build_logic/build_oracle_data.js @@ -0,0 +1,94 @@ +/** + * Build Oracle of Bluegrass Bacon data for static serving + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { BluegrassOracle } from './oracle_of_bluegrass_bacon.js'; +import { initProjectDirs } from './locations.js'; + +// Initialize for cryptograss.live since that's where the Oracle lives +initProjectDirs("cryptograss.live"); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +async function buildOracleData() { + console.log('šŸŽµ Building Oracle of Bluegrass Bacon data...'); + + const oracle = new BluegrassOracle(); + await oracle.buildGraph(); + + // Output to the API directory where the Oracle expects to find it + const apiDir = path.join(path.dirname(__dirname), 'sites', 'cryptograss.live', 'api', 'oracle'); + fs.mkdirSync(apiDir, { recursive: true }); + + // Generate musicians list + const musicians = oracle.getAllMusicians(); + fs.writeFileSync( + path.join(apiDir, 'musicians.json'), + JSON.stringify(musicians, null, 2) + ); + + // Build a lookup object for fast path finding + // Since this is for a website, we'll pre-compute some popular paths + // and create a simplified graph representation + const graphData = { + musicians: {}, + connections: oracle.connections.map(conn => ({ + id: conn.id, + musicians: conn.musicians, + type: conn.type, + context: conn.context, + venue: conn.venue, + location: conn.location, + blockHeight: conn.blockHeight, + songContext: conn.songContext, + song: conn.song + })) + }; + + // Add musician data with connections + for (const [name, data] of oracle.musicians) { + graphData.musicians[name] = { + id: data.id, + name: data.name, + connections: Array.from(data.connections) + }; + } + + // Create adjacency list for client-side pathfinding + const adjacencyList = {}; + for (const musician of musicians) { + adjacencyList[musician] = Array.from(oracle.graph.get(musician)); + } + + const fullData = { + musicians: graphData.musicians, + connections: graphData.connections, + adjacencyList: adjacencyList, + stats: { + totalMusicians: musicians.length, + totalConnections: oracle.connections.length, + buildTime: new Date().toISOString() + } + }; + + fs.writeFileSync( + path.join(apiDir, 'graph.json'), + JSON.stringify(fullData, null, 2) + ); + + console.log(`āœ… Built Oracle data:`); + console.log(` - ${musicians.length} musicians`); + console.log(` - ${oracle.connections.length} connections`); + console.log(` - Output: ${apiDir}`); +} + +// CLI usage +if (import.meta.url === `file://${process.argv[1]}`) { + buildOracleData().catch(console.error); +} + +export { buildOracleData }; \ No newline at end of file diff --git a/src/build_logic/oracle_of_bluegrass_bacon.js b/src/build_logic/oracle_of_bluegrass_bacon.js new file mode 100644 index 00000000..06a1d4e1 --- /dev/null +++ b/src/build_logic/oracle_of_bluegrass_bacon.js @@ -0,0 +1,319 @@ +/** + * Oracle of Bluegrass Bacon - Connect any two musicians through shows and recordings + * Like the Oracle of Bacon (six degrees of Kevin Bacon) but for bluegrass! + */ + +import fs from 'fs'; +import yaml from 'js-yaml'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +class BluegrassOracle { + constructor() { + this.musicians = new Map(); // musician name -> musician object + this.connections = []; // array of connection objects + this.graph = new Map(); // musician name -> Set of connected musician names + } + + /** + * Build the graph from all show and recording data + */ + async buildGraph() { + console.log('Building Oracle of Bluegrass Bacon graph...'); + + // Load show data + await this.loadShowData(); + + // Load studio recording data + await this.loadStudioData(); + + // Build adjacency graph for pathfinding + this.buildAdjacencyGraph(); + + console.log(`Graph built with ${this.musicians.size} musicians and ${this.connections.length} connections`); + return this; + } + + /** + * Load all show files and extract musician connections + */ + async loadShowData() { + const showsDir = path.join(__dirname, '../data/shows'); + const showFiles = fs.readdirSync(showsDir).filter(f => f.endsWith('.yaml')); + + for (const file of showFiles) { + const showPath = path.join(showsDir, file); + const showData = yaml.load(fs.readFileSync(showPath, 'utf8')); + + if (!showData || !showData.ensemble) continue; + + // Extract base ensemble musicians + const showMusicians = Object.keys(showData.ensemble); + this.addMusicians(showMusicians); + + // Create connections between all musicians in this show + this.addShowConnections(showMusicians, showData, file); + + // Handle ensemble modifications (featuring musicians) + if (showData.sets) { + for (const setNum of Object.keys(showData.sets)) { + const set = showData.sets[setNum]; + if (set.songplays) { + for (const songplay of set.songplays) { + if (typeof songplay === 'object' && songplay['ensemble-modifications']?.featuring) { + const featuring = songplay['ensemble-modifications'].featuring; + const featuredMusicians = [...showMusicians, ...featuring]; + this.addMusicians(featuring); + this.addShowConnections(featuredMusicians, showData, file, songplay); + } + } + } + } + } + } + } + + /** + * Load studio recording data from song files + */ + async loadStudioData() { + const songsDir = path.join(__dirname, '../data/songs_and_tunes'); + const songFiles = fs.readdirSync(songsDir).filter(f => f.endsWith('.yaml')); + + for (const file of songFiles) { + const songPath = path.join(songsDir, file); + const songData = yaml.load(fs.readFileSync(songPath, 'utf8')); + + if (!songData?.studio_versions) continue; + + // Process each studio version + for (const versionNum of Object.keys(songData.studio_versions)) { + const version = songData.studio_versions[versionNum]; + + // Each version can have multiple albums/releases + for (const albumName of Object.keys(version)) { + const albumData = version[albumName]; + + if (albumData.ensemble) { + const studioMusicians = Object.keys(albumData.ensemble); + this.addMusicians(studioMusicians); + this.addStudioConnections(studioMusicians, albumName, file); + } + } + } + } + } + + /** + * Add musicians to the musicians map + */ + addMusicians(musicianNames) { + for (const name of musicianNames) { + if (!this.musicians.has(name)) { + this.musicians.set(name, { + id: this.generateId(name), + name: name, + connections: new Set() + }); + } + } + } + + /** + * Add connections between musicians who played at the same show + */ + addShowConnections(musicians, showData, filename, songContext = null) { + const connectionId = `show-${filename}-${songContext ? 'song' : 'base'}`; + + // Create connections between every pair of musicians + for (let i = 0; i < musicians.length; i++) { + for (let j = i + 1; j < musicians.length; j++) { + const connection = { + id: `${connectionId}-${i}-${j}`, + musicians: [musicians[i], musicians[j]], + type: 'show', + context: showData.title || filename, + venue: showData.venue, + location: showData.locality && showData.region1 ? + `${showData.locality}, ${showData.region1}` : + showData.locality || showData.region1, + blockHeight: filename.split('-')[1]?.split('.')[0], // extract from filename + songContext: songContext, + artifacts: [] // TODO: populate with set stones, etc. + }; + + this.connections.push(connection); + this.musicians.get(musicians[i]).connections.add(connection.id); + this.musicians.get(musicians[j]).connections.add(connection.id); + } + } + } + + /** + * Add connections between musicians who recorded together + */ + addStudioConnections(musicians, albumName, filename) { + const connectionId = `studio-${filename}-${albumName}`; + + // Create connections between every pair of musicians + for (let i = 0; i < musicians.length; i++) { + for (let j = i + 1; j < musicians.length; j++) { + const connection = { + id: `${connectionId}-${i}-${j}`, + musicians: [musicians[i], musicians[j]], + type: 'recording', + context: albumName, + song: filename.replace('.yaml', ''), + artifacts: [] // TODO: populate with artifacts + }; + + this.connections.push(connection); + this.musicians.get(musicians[i]).connections.add(connection.id); + this.musicians.get(musicians[j]).connections.add(connection.id); + } + } + } + + /** + * Build adjacency graph for efficient pathfinding + */ + buildAdjacencyGraph() { + for (const musician of this.musicians.keys()) { + this.graph.set(musician, new Set()); + } + + for (const connection of this.connections) { + const [musician1, musician2] = connection.musicians; + this.graph.get(musician1).add(musician2); + this.graph.get(musician2).add(musician1); + } + } + + /** + * Find the shortest path between two musicians using BFS + */ + findPath(startMusician, endMusician) { + if (!this.musicians.has(startMusician)) { + throw new Error(`Musician "${startMusician}" not found in database`); + } + if (!this.musicians.has(endMusician)) { + throw new Error(`Musician "${endMusician}" not found in database`); + } + + if (startMusician === endMusician) { + return { path: [startMusician], connections: [], degrees: 0 }; + } + + const queue = [[startMusician]]; + const visited = new Set([startMusician]); + + while (queue.length > 0) { + const currentPath = queue.shift(); + const currentMusician = currentPath[currentPath.length - 1]; + + const neighbors = this.graph.get(currentMusician); + for (const neighbor of neighbors) { + if (neighbor === endMusician) { + const finalPath = [...currentPath, neighbor]; + const pathConnections = this.getConnectionsForPath(finalPath); + return { + path: finalPath, + connections: pathConnections, + degrees: finalPath.length - 1 + }; + } + + if (!visited.has(neighbor)) { + visited.add(neighbor); + queue.push([...currentPath, neighbor]); + } + } + } + + return null; // No path found + } + + /** + * Get the connections that link musicians in a path + */ + getConnectionsForPath(pathArray) { + const pathConnections = []; + + for (let i = 0; i < pathArray.length - 1; i++) { + const musician1 = pathArray[i]; + const musician2 = pathArray[i + 1]; + + // Find connection between these two musicians + const connection = this.connections.find(conn => + (conn.musicians[0] === musician1 && conn.musicians[1] === musician2) || + (conn.musicians[0] === musician2 && conn.musicians[1] === musician1) + ); + + if (connection) { + pathConnections.push(connection); + } + } + + return pathConnections; + } + + /** + * Get all musicians in the database + */ + getAllMusicians() { + return Array.from(this.musicians.keys()).sort(); + } + + /** + * Generate a URL-safe ID from a name + */ + generateId(name) { + return name.toLowerCase() + .replace(/[^a-z0-9\s]/g, '') + .replace(/\s+/g, '-'); + } +} + +export { BluegrassOracle }; + +// CLI usage +if (import.meta.url === `file://${process.argv[1]}`) { + async function main() { + const oracle = new BluegrassOracle(); + await oracle.buildGraph(); + + const args = process.argv.slice(2); + if (args.length === 0) { + console.log('Available musicians:'); + console.log(oracle.getAllMusicians().join('\n')); + } else if (args.length === 2) { + const [start, end] = args; + console.log(`\nFinding path from "${start}" to "${end}"...`); + + try { + const result = oracle.findPath(start, end); + if (result) { + console.log(`\nšŸŽµ ${result.degrees} degrees of separation!`); + console.log(`Path: ${result.path.join(' → ')}`); + console.log('\nConnections:'); + result.connections.forEach((conn, i) => { + console.log(`${i + 1}. ${conn.type === 'show' ? 'šŸŽ¤' : 'šŸŽ§'} ${conn.context}`); + if (conn.venue) console.log(` at ${conn.venue}${conn.location ? `, ${conn.location}` : ''}`); + if (conn.songContext) console.log(` during "${Object.keys(conn.songContext)[0]}"`); + }); + } else { + console.log('No connection found! 😱'); + } + } catch (error) { + console.error(error.message); + } + } else { + console.log('Usage: node oracle_of_bluegrass_bacon.js [musician1] [musician2]'); + } + } + + main().catch(console.error); +} \ No newline at end of file diff --git a/src/build_logic/webpack.justinholmes.common.js b/src/build_logic/webpack.justinholmes.common.js index f62d038a..0d40916b 100644 --- a/src/build_logic/webpack.justinholmes.common.js +++ b/src/build_logic/webpack.justinholmes.common.js @@ -32,6 +32,8 @@ const htmlPluginInstances = templateFiles.map(templatePath => { var chunks = ['main', 'magic_hat']; } else if (relativePath.startsWith('cryptograss/tools/add-show-for-stone-minting')) { var chunks = ['main', 'add_show_for_stone_minting']; + } else if (relativePath.startsWith('cryptograss/tools/oracle-of-bluegrass-bacon')) { + var chunks = ['main', 'oracle_client']; } else { var chunks = ['main']; } @@ -65,6 +67,11 @@ export default { }, noErrorOnMissing: true }, + { + from: path.resolve(siteDir, 'api'), + to: path.resolve(outputDistDir, 'api'), + noErrorOnMissing: true + }, ] }), new MiniCssExtractPlugin({ @@ -81,6 +88,7 @@ export default { magic_hat: `${frontendJSDir}/magic_hat.js`, strike_set_stone: `${frontendJSDir}/strike_set_stones.js`, add_show_for_stone_minting: `${frontendJSDir}/add_show_for_stone_minting.js`, + oracle_client: `${frontendJSDir}/oracle_client.js`, }, module: { rules: [ diff --git a/src/data/cryptograss.live.pages.yaml b/src/data/cryptograss.live.pages.yaml index eab0bee9..4a5dd3bd 100644 --- a/src/data/cryptograss.live.pages.yaml +++ b/src/data/cryptograss.live.pages.yaml @@ -8,3 +8,11 @@ onchain-merch: include_data_in_context: ["shows", "chainData"] context: page_title: "Onchain Merch" + +oracle-of-bluegrass-bacon: + template: tools/oracle-of-bluegrass-bacon.njk + context: + title: "Oracle of Bluegrass Bacon" + page_title: "Oracle of Bluegrass Bacon" + description: "Connect any two musicians through shows and recordings - like Six Degrees of Kevin Bacon, but for bluegrass!" + no_video_bg: true diff --git a/src/sites/cryptograss.live/js/oracle_client.js b/src/sites/cryptograss.live/js/oracle_client.js new file mode 100644 index 00000000..c9e12e7a --- /dev/null +++ b/src/sites/cryptograss.live/js/oracle_client.js @@ -0,0 +1,136 @@ +/** + * Client-side Oracle of Bluegrass Bacon pathfinding + */ + +class OracleClient { + constructor() { + this.musicians = []; + this.graph = null; + this.connections = []; + this.adjacencyList = {}; + this.loaded = false; + } + + async loadData() { + if (this.loaded) return; + + try { + console.log('Loading Oracle data...'); + + // Load musicians list + const musiciansResponse = await fetch('/api/oracle/musicians.json'); + if (!musiciansResponse.ok) { + throw new Error('Failed to load musicians data'); + } + this.musicians = await musiciansResponse.json(); + + // Load full graph data + const graphResponse = await fetch('/api/oracle/graph.json'); + if (!graphResponse.ok) { + throw new Error('Failed to load graph data'); + } + + const graphData = await graphResponse.json(); + this.graph = graphData.musicians; + this.connections = graphData.connections; + this.adjacencyList = graphData.adjacencyList; + + this.loaded = true; + console.log('Oracle data loaded:', graphData.stats); + } catch (error) { + console.error('Error loading Oracle data:', error); + throw error; + } + } + + getMusicians() { + return this.musicians; + } + + /** + * Find shortest path between two musicians using BFS + */ + findPath(startMusician, endMusician) { + if (!this.loaded) { + throw new Error('Oracle data not loaded'); + } + + if (!this.adjacencyList[startMusician]) { + throw new Error(`Musician "${startMusician}" not found in database`); + } + if (!this.adjacencyList[endMusician]) { + throw new Error(`Musician "${endMusician}" not found in database`); + } + + if (startMusician === endMusician) { + return { path: [startMusician], connections: [], degrees: 0 }; + } + + const queue = [[startMusician]]; + const visited = new Set([startMusician]); + + while (queue.length > 0) { + const currentPath = queue.shift(); + const currentMusician = currentPath[currentPath.length - 1]; + + const neighbors = this.adjacencyList[currentMusician] || []; + for (const neighbor of neighbors) { + if (neighbor === endMusician) { + const finalPath = [...currentPath, neighbor]; + const pathConnections = this.getConnectionsForPath(finalPath); + return { + path: finalPath, + connections: pathConnections, + degrees: finalPath.length - 1 + }; + } + + if (!visited.has(neighbor)) { + visited.add(neighbor); + queue.push([...currentPath, neighbor]); + } + } + } + + return null; // No path found + } + + /** + * Get the connections that link musicians in a path + */ + getConnectionsForPath(pathArray) { + const pathConnections = []; + + for (let i = 0; i < pathArray.length - 1; i++) { + const musician1 = pathArray[i]; + const musician2 = pathArray[i + 1]; + + // Find connection between these two musicians + const connection = this.connections.find(conn => + (conn.musicians[0] === musician1 && conn.musicians[1] === musician2) || + (conn.musicians[0] === musician2 && conn.musicians[1] === musician1) + ); + + if (connection) { + pathConnections.push(connection); + } + } + + return pathConnections; + } + + /** + * Search musicians by partial name match + */ + searchMusicians(query) { + if (!query || query.length < 2) return []; + + const lowerQuery = query.toLowerCase(); + return this.musicians + .filter(musician => musician.toLowerCase().includes(lowerQuery)) + .slice(0, 10); + } +} + +// Export for use in the page +window.OracleClient = OracleClient; \ No newline at end of file diff --git a/src/sites/cryptograss.live/templates/pages/tools/oracle-of-bluegrass-bacon.njk b/src/sites/cryptograss.live/templates/pages/tools/oracle-of-bluegrass-bacon.njk new file mode 100644 index 00000000..99f7067f --- /dev/null +++ b/src/sites/cryptograss.live/templates/pages/tools/oracle-of-bluegrass-bacon.njk @@ -0,0 +1,392 @@ +--- +title: "Oracle of Bluegrass Bacon" +pageId: "oracle-of-bluegrass-bacon" +description: "Connect any two musicians through shows and recordings - like Six Degrees of Kevin Bacon, but for bluegrass!" +--- + +{% extends '../../base.njk' %} + +{% block head %} + +{% endblock %} + +{% block main %} +
    +

    šŸŽµ Oracle of Bluegrass Bacon

    +

    Connect any two musicians through shows and recordings! Like the famous "Six Degrees of Kevin Bacon," but for the cryptograss universe. Find the shortest path between any two musicians who have appeared on our records or performed at our shows.

    + + + + + + +
    + + +{% endblock %} \ No newline at end of file diff --git a/src/sites/justinholmes.com/js/oracle_client.js b/src/sites/justinholmes.com/js/oracle_client.js new file mode 100644 index 00000000..c9e12e7a --- /dev/null +++ b/src/sites/justinholmes.com/js/oracle_client.js @@ -0,0 +1,136 @@ +/** + * Client-side Oracle of Bluegrass Bacon pathfinding + */ + +class OracleClient { + constructor() { + this.musicians = []; + this.graph = null; + this.connections = []; + this.adjacencyList = {}; + this.loaded = false; + } + + async loadData() { + if (this.loaded) return; + + try { + console.log('Loading Oracle data...'); + + // Load musicians list + const musiciansResponse = await fetch('/api/oracle/musicians.json'); + if (!musiciansResponse.ok) { + throw new Error('Failed to load musicians data'); + } + this.musicians = await musiciansResponse.json(); + + // Load full graph data + const graphResponse = await fetch('/api/oracle/graph.json'); + if (!graphResponse.ok) { + throw new Error('Failed to load graph data'); + } + + const graphData = await graphResponse.json(); + this.graph = graphData.musicians; + this.connections = graphData.connections; + this.adjacencyList = graphData.adjacencyList; + + this.loaded = true; + console.log('Oracle data loaded:', graphData.stats); + } catch (error) { + console.error('Error loading Oracle data:', error); + throw error; + } + } + + getMusicians() { + return this.musicians; + } + + /** + * Find shortest path between two musicians using BFS + */ + findPath(startMusician, endMusician) { + if (!this.loaded) { + throw new Error('Oracle data not loaded'); + } + + if (!this.adjacencyList[startMusician]) { + throw new Error(`Musician "${startMusician}" not found in database`); + } + if (!this.adjacencyList[endMusician]) { + throw new Error(`Musician "${endMusician}" not found in database`); + } + + if (startMusician === endMusician) { + return { path: [startMusician], connections: [], degrees: 0 }; + } + + const queue = [[startMusician]]; + const visited = new Set([startMusician]); + + while (queue.length > 0) { + const currentPath = queue.shift(); + const currentMusician = currentPath[currentPath.length - 1]; + + const neighbors = this.adjacencyList[currentMusician] || []; + for (const neighbor of neighbors) { + if (neighbor === endMusician) { + const finalPath = [...currentPath, neighbor]; + const pathConnections = this.getConnectionsForPath(finalPath); + return { + path: finalPath, + connections: pathConnections, + degrees: finalPath.length - 1 + }; + } + + if (!visited.has(neighbor)) { + visited.add(neighbor); + queue.push([...currentPath, neighbor]); + } + } + } + + return null; // No path found + } + + /** + * Get the connections that link musicians in a path + */ + getConnectionsForPath(pathArray) { + const pathConnections = []; + + for (let i = 0; i < pathArray.length - 1; i++) { + const musician1 = pathArray[i]; + const musician2 = pathArray[i + 1]; + + // Find connection between these two musicians + const connection = this.connections.find(conn => + (conn.musicians[0] === musician1 && conn.musicians[1] === musician2) || + (conn.musicians[0] === musician2 && conn.musicians[1] === musician1) + ); + + if (connection) { + pathConnections.push(connection); + } + } + + return pathConnections; + } + + /** + * Search musicians by partial name match + */ + searchMusicians(query) { + if (!query || query.length < 2) return []; + + const lowerQuery = query.toLowerCase(); + return this.musicians + .filter(musician => musician.toLowerCase().includes(lowerQuery)) + .slice(0, 10); + } +} + +// Export for use in the page +window.OracleClient = OracleClient; \ No newline at end of file diff --git a/src/sites/justinholmes.com/templates/pages/cryptograss/tools/oracle-of-bluegrass-bacon.njk b/src/sites/justinholmes.com/templates/pages/cryptograss/tools/oracle-of-bluegrass-bacon.njk new file mode 100644 index 00000000..2c40a563 --- /dev/null +++ b/src/sites/justinholmes.com/templates/pages/cryptograss/tools/oracle-of-bluegrass-bacon.njk @@ -0,0 +1,376 @@ +--- +title: "Oracle of Bluegrass Bacon" +pageId: "oracle-of-bluegrass-bacon" +description: "Connect any two musicians through shows and recordings - like Six Degrees of Kevin Bacon, but for bluegrass!" +--- + +{% extends "shared/layouts/base.njk" %} + +{% block head %} + +{% endblock %} + +{% block content %} +
    +

    šŸŽµ Oracle of Bluegrass Bacon

    +

    Connect any two musicians through shows and recordings! Like the famous "Six Degrees of Kevin Bacon," but for the cryptograss universe. Find the shortest path between any two musicians who have appeared on our records or performed at our shows.

    + + + + + + +
    + + +{% endblock %} \ No newline at end of file