Files
Starlight_Lancher/apps/app-frontend/src/lab/recipe-generator/recipe-engine.ts

393 lines
12 KiB
TypeScript

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<typeof parseIdentifier>
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>,
): 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<string, SlotValue>; reverse: Record<string, string> } {
const cells: { reverseKey: string; path: string; value: SlotValue }[] = []
const counts = new Map<string, number>()
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<string, SlotValue> = {}
const reverse: Record<string, string> = {}
const usedKeys = new Set<string>(['#'])
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<string, string>,
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<string, unknown> {
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<string, unknown> = {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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)
}