import { createJavaFormatter, type JavaRecipeFormatter } from './formatter.ts' import { fullId, parseIdentifier } from './identifier.ts' import type { JavaVersionId, RecipeSlot, RecipeSlotContext, RecipeState, SlotValue, } from './types.ts' import { DEFAULT_COOKING_TIME, isVersionAtLeast, supportsRecipeCategory, supportsShowNotification, supportsSmithingTrimPattern, } from './versions.ts' export const CRAFTING_GRID_SLOTS: readonly RecipeSlot[] = [ 'crafting.1', 'crafting.2', 'crafting.3', 'crafting.4', 'crafting.5', 'crafting.6', 'crafting.7', 'crafting.8', 'crafting.9', ] const TWO_BY_TWO_DISABLED_INDICES = new Set([2, 5, 6, 7, 8]) const PATTERN_CHARACTERS = ['#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ', ...'abcdefghijklmnopqrstuvwxyz'] const DINNERBONE_RULES: { char: string; keywords: string[] }[] = [ { char: '/', keywords: ['stick', 'rod', 'torch', 'arrow', 'bone'] }, { char: '_', keywords: ['slab', 'carpet', 'paper', 'map'] }, { char: '=', keywords: ['ingot', 'brick'] }, { char: '.', keywords: ['nugget', 'dust', 'powder', 'seed', 'redstone'] }, { char: 'o', keywords: ['diamond', 'emerald', 'quartz', 'shard', 'pearl', 'ball', 'egg'] }, { char: '~', keywords: ['string', 'vine'] }, { char: ')', keywords: ['bow'] }, { char: 'u', keywords: ['bucket', 'bottle'] }, ] export type ResolvedSlotValue = { ref: ReturnType isTag: boolean } export function resolveSlotValue( value: SlotValue | undefined, ctx: RecipeSlotContext, ): ResolvedSlotValue | undefined { if (!value) return undefined switch (value.kind) { case 'item': return { ref: parseIdentifier(value.id), isTag: false } case 'vanilla_tag': return { ref: parseIdentifier(value.id), isTag: true } case 'custom_item': { const custom = ctx.customItemsByUid[value.uid] return custom ? { ref: parseIdentifier(custom.id), isTag: false } : undefined } case 'custom_tag': { const custom = ctx.customTagsByUid[value.uid] return custom ? { ref: parseIdentifier(custom.id), isTag: true } : undefined } } } export function slotCount(value: SlotValue | undefined): number | undefined { return value && (value.kind === 'item' || value.kind === 'custom_item') ? value.count : undefined } function dinnerboneChallenge(path: string, isTag: boolean): string | null { if (isTag) return null const normalized = path.toLowerCase() for (const rule of DINNERBONE_RULES) { if (rule.keywords.some((keyword) => normalized.includes(keyword))) return rule.char } return null } function pickKeyName( path: string, value: SlotValue, ctx: RecipeSlotContext, usedKeys: Set, ): string { const resolved = resolveSlotValue(value, ctx) const candidates = [dinnerboneChallenge(path, resolved?.isTag === true)] for (const word of path.match(/[a-zA-Z]+/g) ?? []) { candidates.push(word[0].toUpperCase(), word[0].toLowerCase()) } for (const letter of path.match(/[a-zA-Z]/g) ?? []) { candidates.push(letter.toUpperCase(), letter.toLowerCase()) } for (const candidate of candidates) { if (candidate && !usedKeys.has(candidate)) return candidate } const next = PATTERN_CHARACTERS.find((candidate) => !usedKeys.has(candidate)) if (!next) throw new Error('Ran out of pattern characters') return next } function resolveKeyInfo( value: SlotValue, ctx: RecipeSlotContext, ): { reverseKey: string; path: string } { const resolved = resolveSlotValue(value, ctx) if (resolved) return { reverseKey: fullId(resolved.ref), path: resolved.ref.id } const uid = value.kind === 'custom_item' || value.kind === 'custom_tag' ? value.uid : '' return { reverseKey: `${value.kind}:${uid}`, path: uid } } export function assignCraftingKeys( grid: (SlotValue | undefined)[], ctx: RecipeSlotContext, ): { key: Record; reverse: Record } { const cells: { reverseKey: string; path: string; value: SlotValue }[] = [] const counts = new Map() let primary: string | undefined let primaryCount = 0 for (const value of grid) { if (!value) continue const info = resolveKeyInfo(value, ctx) cells.push({ reverseKey: info.reverseKey, path: info.path, value }) const count = (counts.get(info.reverseKey) ?? 0) + 1 counts.set(info.reverseKey, count) if (count > primaryCount) { primary = info.reverseKey primaryCount = count } } const key: Record = {} const reverse: Record = {} const usedKeys = new Set(['#']) for (const cell of cells) { if (reverse[cell.reverseKey]) continue const keyName = cell.reverseKey === primary ? '#' : pickKeyName(cell.path, cell.value, ctx, usedKeys) key[keyName] = cell.value reverse[cell.reverseKey] = keyName usedKeys.add(keyName) } return { key, reverse } } export function buildPattern( grid: (SlotValue | undefined)[], reverse: Record, ctx: RecipeSlotContext, keepWhitespace: boolean, ): string[] { const pattern: string[] = [] for (const [index, value] of grid.entries()) { const rowIndex = Math.floor(index / 3) pattern[rowIndex] = pattern[rowIndex] ?? '' if (!value) { pattern[rowIndex] += ' ' continue } const { reverseKey } = resolveKeyInfo(value, ctx) pattern[rowIndex] += reverse[reverseKey] ?? '#' } if (keepWhitespace) return pattern while (pattern.length > 0 && pattern[0].trim() === '') pattern.shift() while (pattern.length > 0 && pattern[pattern.length - 1].trim() === '') pattern.pop() if (pattern.length === 0) return pattern let minColumn = Number.POSITIVE_INFINITY let maxColumn = 0 for (const row of pattern) { let firstNonWhitespace = -1 let lastNonWhitespace = -1 for (let index = 0; index < row.length; index += 1) { if (row[index] === ' ') continue if (firstNonWhitespace === -1) firstNonWhitespace = index lastNonWhitespace = index } if (firstNonWhitespace === -1) continue minColumn = Math.min(minColumn, firstNonWhitespace) maxColumn = Math.max(maxColumn, lastNonWhitespace + 1) } return pattern.map((row) => row.slice(minColumn, maxColumn)) } function craftingGrid(state: RecipeState): (SlotValue | undefined)[] { return CRAFTING_GRID_SLOTS.map((slot, index) => { const value = state.slots[slot] if (state.crafting.twoByTwo && TWO_BY_TWO_DISABLED_INDICES.has(index)) return undefined return value }) } function buildCrafting( state: RecipeState, version: JavaVersionId, ctx: RecipeSlotContext, fmt: JavaRecipeFormatter, ): Record { const grid = craftingGrid(state) const populated = grid.filter((value): value is SlotValue => Boolean(value)) const result = state.slots['crafting.result'] const resolvedResult = result ? resolveSlotValue(result, ctx) : undefined const resultCount = slotCount(result) const group = state.group.length > 0 ? state.group : undefined const category = supportsRecipeCategory(version, 'crafting') && state.category.length > 0 ? state.category : undefined const showNotification = supportsShowNotification(version, 'crafting', state.crafting.shapeless) && state.showNotification === false ? { show_notification: false } : {} const output: Record = { type: fmt.typeName(state.crafting.shapeless ? 'crafting_shapeless' : 'crafting_shaped'), ...(category ? { category } : {}), ...showNotification, } if (state.crafting.shapeless) { return { ...output, ingredients: populated.map((value) => { const resolved = resolveSlotValue(value, ctx) return fmt.ingredient(resolved!.ref, resolved!.isTag) }), ...(group ? { group } : {}), result: resolvedResult && !resolvedResult.isTag ? fmt.result(resolvedResult.ref, resultCount) : {}, } } const { key, reverse } = assignCraftingKeys(grid, ctx) return { ...output, pattern: buildPattern(grid, reverse, ctx, state.crafting.keepWhitespace), key: Object.fromEntries( Object.entries(key).map(([keyName, value]) => { const resolved = resolveSlotValue(value, ctx) return [keyName, fmt.ingredient(resolved!.ref, resolved!.isTag)] }), ), ...(group ? { group } : {}), result: resolvedResult && !resolvedResult.isTag ? fmt.result(resolvedResult.ref, resultCount) : {}, } } function buildCooking( state: RecipeState, version: JavaVersionId, ctx: RecipeSlotContext, fmt: JavaRecipeFormatter, type: 'smelting' | 'blasting' | 'smoking' | 'campfire_cooking', ): Record { const ingredient = resolveSlotValue(state.slots['cooking.ingredient'], ctx) const result = resolveSlotValue(state.slots['cooking.result'], ctx) const group = state.group.length > 0 ? state.group : undefined const category = supportsRecipeCategory(version, state.recipeType) && state.category.length > 0 ? state.category : undefined const showNotification = supportsShowNotification(version, state.recipeType, false) && state.showNotification === false ? { show_notification: false } : {} return { type: fmt.typeName(type), ...(category ? { category } : {}), ...(group ? { group } : {}), ...showNotification, experience: state.cooking.experience, cookingtime: state.cooking.time ?? DEFAULT_COOKING_TIME[type], ingredient: ingredient ? fmt.ingredient(ingredient.ref, ingredient.isTag) : {}, result: result && !result.isTag ? fmt.cookingResult(result.ref, slotCount(state.slots['cooking.result'])) : {}, } } function buildStonecutter( state: RecipeState, version: JavaVersionId, ctx: RecipeSlotContext, fmt: JavaRecipeFormatter, ): Record { const ingredient = resolveSlotValue(state.slots['stonecutter.ingredient'], ctx) const result = resolveSlotValue(state.slots['stonecutter.result'], ctx) const group = !isVersionAtLeast(version, '26.1') && state.group.length > 0 ? state.group : undefined const showNotification = supportsShowNotification(version, 'stonecutter', false) && state.showNotification === false ? { show_notification: false } : {} return { type: fmt.typeName('stonecutting'), ...(group ? { group } : {}), ...showNotification, ingredient: ingredient ? fmt.ingredient(ingredient.ref, ingredient.isTag) : {}, ...fmt.stonecutterResult( result?.ref ?? { namespace: 'minecraft', id: 'air' }, slotCount(state.slots['stonecutter.result']), ), } } function buildSmithing( state: RecipeState, version: JavaVersionId, ctx: RecipeSlotContext, fmt: JavaRecipeFormatter, ): Record { const template = resolveSlotValue(state.slots['smithing.template'], ctx) const base = resolveSlotValue(state.slots['smithing.base'], ctx) const addition = resolveSlotValue(state.slots['smithing.addition'], ctx) const result = resolveSlotValue(state.slots['smithing.result'], ctx) if (state.recipeType === 'smithing') { return { type: fmt.typeName('smithing'), result: result && !result.isTag ? fmt.result(result.ref) : {}, base: base ? fmt.ingredient(base.ref, base.isTag) : {}, addition: addition ? fmt.ingredient(addition.ref, addition.isTag) : {}, } } const showNotification = supportsShowNotification(version, state.recipeType, false) && state.showNotification === false ? { show_notification: false } : {} if (state.recipeType === 'smithing_trim') { return { type: fmt.typeName('smithing_trim'), ...showNotification, template: template ? fmt.ingredient(template.ref, template.isTag) : {}, base: base ? fmt.ingredient(base.ref, base.isTag) : {}, addition: addition ? fmt.ingredient(addition.ref, addition.isTag) : {}, ...(supportsSmithingTrimPattern(version) && state.smithing.trimPattern ? { pattern: state.smithing.trimPattern } : {}), } } return { type: fmt.typeName('smithing_transform'), ...showNotification, template: template ? fmt.ingredient(template.ref, template.isTag) : {}, base: base ? fmt.ingredient(base.ref, base.isTag) : {}, addition: addition ? fmt.ingredient(addition.ref, addition.isTag) : {}, result: result && !result.isTag ? fmt.result(result.ref) : {}, } } export function generateJavaRecipe( state: RecipeState, version: JavaVersionId, ctx: RecipeSlotContext, ): Record { const fmt = createJavaFormatter(version) switch (state.recipeType) { case 'crafting': return buildCrafting(state, version, ctx, fmt) case 'smelting': case 'blasting': case 'smoking': case 'campfire_cooking': return buildCooking(state, version, ctx, fmt, state.recipeType) case 'stonecutter': return buildStonecutter(state, version, ctx, fmt) case 'smithing': case 'smithing_trim': case 'smithing_transform': return buildSmithing(state, version, ctx, fmt) default: throw new Error(`Unsupported recipe type: ${state.recipeType satisfies never}`) } } export function getCraftingGridValues(state: RecipeState): (SlotValue | undefined)[] { return craftingGrid(state) }