feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View File

@ -0,0 +1,65 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
createDefaultGradientTextState,
GRADIENT_TEXT_STORAGE_KEY,
loadGradientTextState,
parseGradientPresets,
saveGradientTextState,
serializeGradientPresets,
} from './gradient-storage.ts'
function createStorage(initial: Record<string, string> = {}) {
const data = new Map(Object.entries(initial))
return {
getItem(key: string) {
return data.get(key) ?? null
},
setItem(key: string, value: string) {
data.set(key, value)
},
}
}
test('recovers from corrupt local storage', () => {
const state = loadGradientTextState(createStorage({ [GRADIENT_TEXT_STORAGE_KEY]: '{bad json' }))
assert.deepEqual(state.colors, ['#A855F7', '#22C55E'])
assert.equal(state.adapterId, 'vanilla')
assert.equal(state.vanillaCharacter, '§')
})
test('defaults vanilla output to the section sign without overwriting a saved ampersand', () => {
assert.equal(createDefaultGradientTextState().vanillaCharacter, '§')
const storage = createStorage({
[GRADIENT_TEXT_STORAGE_KEY]: JSON.stringify({
...createDefaultGradientTextState(),
vanillaCharacter: '&',
}),
})
assert.equal(loadGradientTextState(storage).vanillaCharacter, '&')
})
test('persists only valid normalized local state', () => {
const storage = createStorage()
const state = createDefaultGradientTextState()
state.colors = ['#abc']
state.adapterId = 'minimessage'
saveGradientTextState(state, storage)
assert.deepEqual(loadGradientTextState(storage).colors, ['#AABBCC'])
assert.equal(loadGradientTextState(storage).adapterId, 'minimessage')
})
test('rejects invalid imported presets and round-trips valid presets', () => {
const presets = parseGradientPresets([
{ name: 'Sunset', colors: ['#FF0000', '#00FF00'] },
{ name: 'Solid', colors: ['#112233'] },
{ name: 'Broken', colors: ['not-a-color'] },
])
assert.equal(presets.length, 2)
assert.deepEqual(JSON.parse(serializeGradientPresets(presets)), [
{ name: 'Sunset', colors: ['#FF0000', '#00FF00'] },
{ name: 'Solid', colors: ['#112233'] },
])
})

View File

@ -0,0 +1,136 @@
import {
cloneGradientDocument,
DEFAULT_GRADIENT_COLORS,
DEFAULT_GRADIENT_DOCUMENT,
gradientFormatAdapters,
type GradientFormatId,
type GradientTextDocument,
normalizeGradientDocument,
normalizeHexColor,
} from './gradient-text.ts'
export type GradientPreset = {
id: string
name: string
colors: string[]
createdAt: string
}
export type GradientTextLabState = {
version: 1
document: GradientTextDocument
colors: string[]
adapterId: GradientFormatId
vanillaCharacter: '&' | '§'
simplifyGradients: boolean
presets: GradientPreset[]
}
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>
export const GRADIENT_TEXT_STORAGE_KEY = 'axolotl.lab.gradient-text.v1'
export function createDefaultGradientTextState(): GradientTextLabState {
return {
version: 1,
document: cloneGradientDocument(DEFAULT_GRADIENT_DOCUMENT),
colors: [...DEFAULT_GRADIENT_COLORS],
adapterId: 'vanilla',
vanillaCharacter: '§',
simplifyGradients: false,
presets: [],
}
}
export function loadGradientTextState(
storage: StorageLike | null = getBrowserStorage(),
): GradientTextLabState {
const fallback = createDefaultGradientTextState()
if (!storage) return fallback
try {
const raw = storage.getItem(GRADIENT_TEXT_STORAGE_KEY)
if (!raw) return fallback
return sanitizeGradientTextState(JSON.parse(raw), fallback)
} catch {
return fallback
}
}
export function saveGradientTextState(
state: GradientTextLabState,
storage: StorageLike | null = getBrowserStorage(),
): void {
if (!storage) return
storage.setItem(GRADIENT_TEXT_STORAGE_KEY, JSON.stringify(sanitizeGradientTextState(state)))
}
export function parseGradientPresets(value: unknown): GradientPreset[] {
if (!Array.isArray(value)) return []
return value.flatMap((preset, index) => {
if (!preset || typeof preset !== 'object') return []
const record = preset as Partial<GradientPreset>
const colors = sanitizeColors(record.colors)
if (!colors.length) return []
return [
{
id:
typeof record.id === 'string' && record.id
? record.id
: `imported-${index}-${Date.now()}`,
name:
typeof record.name === 'string' && record.name.trim()
? record.name.trim().slice(0, 80)
: `Preset ${index + 1}`,
colors,
createdAt:
typeof record.createdAt === 'string' && !Number.isNaN(Date.parse(record.createdAt))
? record.createdAt
: new Date().toISOString(),
},
]
})
}
export function serializeGradientPresets(presets: GradientPreset[]): string {
return JSON.stringify(
presets.map(({ name, colors }) => ({ name, colors })),
null,
2,
)
}
function sanitizeGradientTextState(
value: unknown,
fallback = createDefaultGradientTextState(),
): GradientTextLabState {
if (!value || typeof value !== 'object') return fallback
const record = value as Partial<GradientTextLabState>
const adapterId = gradientFormatAdapters.some((adapter) => adapter.id === record.adapterId)
? (record.adapterId as GradientFormatId)
: fallback.adapterId
const colors = sanitizeColors(record.colors)
return {
version: 1,
document: record.document ? normalizeGradientDocument(record.document) : fallback.document,
colors: colors.length ? colors : fallback.colors,
adapterId,
vanillaCharacter:
record.vanillaCharacter === '&' || record.vanillaCharacter === '§'
? record.vanillaCharacter
: fallback.vanillaCharacter,
simplifyGradients: record.simplifyGradients === true,
presets: parseGradientPresets(record.presets),
}
}
function sanitizeColors(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value
.map((color) => (typeof color === 'string' ? normalizeHexColor(color) : null))
.filter((color): color is string => color !== null)
}
function getBrowserStorage(): StorageLike | null {
return typeof window === 'undefined' ? null : window.localStorage
}

View File

@ -0,0 +1,115 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildGradientCharacters,
createDocumentFromPlainText,
generateGradientOutput,
getMinecraftTextShadow,
gradientFormatAdapters,
interpolateGradient,
parseGradientColors,
type TextFormat,
} from './gradient-text.ts'
const options = { vanillaCharacter: '&' as const, simplifyGradients: false }
test('interpolates every color stop including both endpoints', () => {
assert.deepEqual(interpolateGradient(['#000000', '#FFFFFF'], 3), [
'#000000',
'#808080',
'#FFFFFF',
])
assert.deepEqual(interpolateGradient(['#FF0000', '#00FF00', '#0000FF'], 5), [
'#FF0000',
'#808000',
'#00FF00',
'#008080',
'#0000FF',
])
})
test('does not spend a gradient color on whitespace and supports Unicode code points', () => {
const characters = buildGradientCharacters(createDocumentFromPlainText('A 🦎'), [
'#000000',
'#FFFFFF',
])
assert.deepEqual(
characters.map((character) => [character.character, character.color]),
[
['A', '#000000'],
[' ', null],
['🦎', '#FFFFFF'],
],
)
})
test('uses one color stop as a solid text color', () => {
const characters = buildGradientCharacters(createDocumentFromPlainText('Solid'), ['#12AB34'])
assert.deepEqual(
characters.map((character) => character.color),
['#12AB34', '#12AB34', '#12AB34', '#12AB34', '#12AB34'],
)
assert.equal(
generateGradientOutput(createDocumentFromPlainText('A'), ['#12AB34'], 'vanilla', options),
'&#12AB34A',
)
})
test('matches Minecraft preview text shadow colors', () => {
assert.equal(getMinecraftTextShadow('#FF0000'), '#800000')
assert.equal(getMinecraftTextShadow(null), '#000000')
})
test('keeps multiline text and formatting in generated output', () => {
const document = {
lines: [[{ text: 'A', formats: ['bold', 'italic'] as const }], [{ text: 'B', formats: [] }]],
}
assert.equal(
generateGradientOutput(document, ['#123456', '#ABCDEF'], 'vanilla', options),
'&#123456&l&oA\n&#ABCDEFB',
)
assert.equal(
generateGradientOutput(document, ['#123456', '#ABCDEF'], 'json', options),
'[{"text":"A","color":"#123456","bold":true,"italic":true},{"text":"\\n"},{"text":"B","color":"#ABCDEF"}]',
)
})
test('uses the selected legacy control character and resets formatting with the next color', () => {
const document = {
lines: [
[
{ text: 'A', formats: ['bold'] as TextFormat[] },
{ text: 'B', formats: [] as TextFormat[] },
],
],
}
assert.equal(
generateGradientOutput(document, ['#000000', '#FFFFFF'], 'vanilla', options),
'&#000000&lA&#FFFFFFB',
)
assert.equal(
generateGradientOutput(document, ['#000000', '#FFFFFF'], 'vanilla', {
vanillaCharacter: '§',
simplifyGradients: false,
}),
'§#000000§lA§#FFFFFFB',
)
})
test('all registered adapters generate a non-empty result', () => {
const document = createDocumentFromPlainText('Lab')
assert.equal(gradientFormatAdapters.length, 19)
for (const adapter of gradientFormatAdapters) {
const output = generateGradientOutput(document, ['#AA00FF', '#00FFAA'], adapter.id, options)
assert.notEqual(output, '', adapter.id)
}
})
test('parses HEX, RGB, and CSS gradient color input', () => {
assert.deepEqual(parseGradientColors('linear-gradient(90deg, #a0b, rgb(12, 34, 56), #ABCDEF)'), [
'#AA00BB',
'#ABCDEF',
'#0C2238',
])
})

View File

@ -0,0 +1,746 @@
export const TEXT_FORMATS = ['bold', 'italic', 'underlined', 'strikethrough', 'obfuscated'] as const
export type TextFormat = (typeof TEXT_FORMATS)[number]
export type GradientTextRun = {
text: string
formats: TextFormat[]
}
export type GradientTextDocument = {
lines: GradientTextRun[][]
}
export type GradientFormatId =
| 'vanilla'
| 'vanilla-compatible'
| 'standard'
| 'cmi'
| 'minimessage'
| 'minimessage-gradient'
| 'minedown'
| 'snbt'
| 'trchat'
| 'taboolib'
| 'taboolib-gradient'
| 'rosegarden-gradient'
| 'chat-colors'
| 'motd'
| 'bbcode'
| 'json'
| 'html'
| 'csv'
| 'terraria'
export type GradientFormatAdapter = {
id: GradientFormatId
label: string
sample: string
mimeType: string
extension: string
supportsVanillaCharacter?: boolean
supportsSimplify?: boolean
}
export type GradientOutputOptions = {
vanillaCharacter: '&' | '§'
simplifyGradients: boolean
}
type GradientCharacter = {
character: string
color: string | null
formats: TextFormat[]
newline?: boolean
}
type Color = {
red: number
green: number
blue: number
}
export const DEFAULT_GRADIENT_COLORS = ['#A855F7', '#22C55E']
export const DEFAULT_GRADIENT_DOCUMENT: GradientTextDocument = {
lines: [[{ text: 'Axolotl', formats: [] }]],
}
export const gradientFormatAdapters: readonly GradientFormatAdapter[] = [
{
id: 'vanilla',
label: 'Vanilla',
sample: '&#RRGGBB',
mimeType: 'text/plain',
extension: 'txt',
supportsVanillaCharacter: true,
},
{
id: 'vanilla-compatible',
label: 'Vanilla compatible',
sample: '&x&R&R&G&G&B&B',
mimeType: 'text/plain',
extension: 'txt',
supportsVanillaCharacter: true,
},
{
id: 'standard',
label: 'Standard HEX',
sample: '#RRGGBB',
mimeType: 'text/plain',
extension: 'txt',
},
{ id: 'cmi', label: 'CMI', sample: '{#RRGGBB}', mimeType: 'text/plain', extension: 'txt' },
{
id: 'minimessage',
label: 'MiniMessage',
sample: '<#RRGGBB>',
mimeType: 'text/plain',
extension: 'txt',
},
{
id: 'minimessage-gradient',
label: 'MiniMessage gradient',
sample: '<gradient:#RRGGBB:#RRGGBB>',
mimeType: 'text/plain',
extension: 'txt',
supportsSimplify: true,
},
{
id: 'minedown',
label: 'MineDown',
sample: '&#RRGGBB&',
mimeType: 'text/plain',
extension: 'txt',
},
{
id: 'snbt',
label: 'Stringified NBT',
sample: "{text:'T',color:'#RRGGBB'}",
mimeType: 'application/json',
extension: 'snbt',
},
{ id: 'trchat', label: 'TrChat', sample: '&{#RRGGBB}', mimeType: 'text/plain', extension: 'txt' },
{
id: 'taboolib',
label: 'TabooLib',
sample: '&{#RRGGBB}',
mimeType: 'text/plain',
extension: 'txt',
},
{
id: 'taboolib-gradient',
label: 'TabooLib gradient',
sample: '[Text](gradient=#RRGGBB,#RRGGBB)',
mimeType: 'text/plain',
extension: 'txt',
supportsSimplify: true,
},
{
id: 'rosegarden-gradient',
label: 'RoseGarden gradient',
sample: '<g:#RRGGBB:#RRGGBB>Text',
mimeType: 'text/plain',
extension: 'txt',
supportsSimplify: true,
},
{
id: 'chat-colors',
label: 'Chat Colors',
sample: '[#RRGGBB]',
mimeType: 'text/plain',
extension: 'txt',
},
{ id: 'motd', label: 'MOTD', sample: '\\u00A7x', mimeType: 'text/plain', extension: 'txt' },
{
id: 'bbcode',
label: 'BBCode',
sample: '[color=#RRGGBB]Text[/color]',
mimeType: 'text/plain',
extension: 'txt',
},
{
id: 'json',
label: 'JSON text component',
sample: '{"text":"T","color":"#RRGGBB"}',
mimeType: 'application/json',
extension: 'json',
},
{
id: 'html',
label: 'HTML',
sample: '<span style="color: #RRGGBB">Text</span>',
mimeType: 'text/html',
extension: 'html',
},
{ id: 'csv', label: 'CSV', sample: '#RRGGBB,T', mimeType: 'text/csv', extension: 'csv' },
{
id: 'terraria',
label: 'Terraria',
sample: '[c/RRGGBB:T]',
mimeType: 'text/plain',
extension: 'txt',
},
]
export function cloneGradientDocument(document: GradientTextDocument): GradientTextDocument {
return {
lines: document.lines.map((line) =>
line.map((run) => ({ text: run.text, formats: [...run.formats] })),
),
}
}
export function createDocumentFromPlainText(text: string): GradientTextDocument {
return {
lines: text.split('\n').map((line) => [{ text: line, formats: [] }]),
}
}
export function plainTextFromDocument(document: GradientTextDocument): string {
return document.lines.map((line) => line.map((run) => run.text).join('')).join('\n')
}
export function normalizeHexColor(value: string): string | null {
const match = value.trim().match(/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i)
if (!match) return null
const normalized =
match[1].length === 3
? match[1]
.split('')
.map((part) => `${part}${part}`)
.join('')
: match[1]
return `#${normalized.toUpperCase()}`
}
export function parseGradientColors(value: string): string[] {
const colors: string[] = []
const hexMatches = value.match(/#(?:[0-9a-f]{3}|[0-9a-f]{6})\b/gi) ?? []
for (const match of hexMatches) {
const color = normalizeHexColor(match)
if (color) colors.push(color)
}
const rgbExpression = /rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/gi
for (const match of value.matchAll(rgbExpression)) {
const components = match.slice(1, 4).map(Number)
if (components.every((component) => component >= 0 && component <= 255)) {
colors.push(colorToHex({ red: components[0], green: components[1], blue: components[2] }))
}
}
return [...new Set(colors)]
}
export function randomGradientColor(random = Math.random): string {
return colorToHex({
red: Math.floor(random() * 256),
green: Math.floor(random() * 256),
blue: Math.floor(random() * 256),
})
}
export function getMinecraftTextShadow(color: string | null): string {
const normalized = color ? normalizeHexColor(color) : null
if (!normalized) return '#000000'
const source = hexToColor(normalized)
const red = source.red / 255
const green = source.green / 255
const blue = source.blue / 255
const maximum = Math.max(red, green, blue)
const minimum = Math.min(red, green, blue)
const delta = maximum - minimum
const saturation = maximum === 0 ? 0 : delta / maximum
let hue = 0
if (delta) {
if (maximum === red) hue = ((green - blue) / delta) % 6
else if (maximum === green) hue = (blue - red) / delta + 2
else hue = (red - green) / delta + 4
hue = ((hue * 60 + 360) % 360) / 360
}
const lightness = maximum / 4
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation
const segment = hue * 6
const secondary = chroma * (1 - Math.abs((segment % 2) - 1))
const match = lightness - chroma / 2
const [shadowRed, shadowGreen, shadowBlue] =
segment < 1
? [chroma, secondary, 0]
: segment < 2
? [secondary, chroma, 0]
: segment < 3
? [0, chroma, secondary]
: segment < 4
? [0, secondary, chroma]
: segment < 5
? [secondary, 0, chroma]
: [chroma, 0, secondary]
return colorToHex({
red: Math.round((shadowRed + match) * 255),
green: Math.round((shadowGreen + match) * 255),
blue: Math.round((shadowBlue + match) * 255),
})
}
export function interpolateGradient(colors: string[], count: number): string[] {
const normalized = colors
.map(normalizeHexColor)
.filter((color): color is string => color !== null)
if (count <= 0 || normalized.length === 0) return []
if (normalized.length === 1 || count === 1)
return Array.from({ length: count }, () => normalized[0])
const source = normalized.map(hexToColor)
return Array.from({ length: count }, (_, index) => {
const position = index / (count - 1)
const scaled = position * (source.length - 1)
const startIndex = Math.floor(scaled)
const endIndex = Math.min(source.length - 1, startIndex + 1)
const progress = scaled - startIndex
const start = source[startIndex]
const end = source[endIndex]
return colorToHex({
red: Math.round(start.red + (end.red - start.red) * progress),
green: Math.round(start.green + (end.green - start.green) * progress),
blue: Math.round(start.blue + (end.blue - start.blue) * progress),
})
})
}
export function buildGradientCharacters(
document: GradientTextDocument,
colors: string[],
): GradientCharacter[] {
const safeDocument = normalizeGradientDocument(document)
const uncoloredCharacterCount = safeDocument.lines.reduce(
(total, line) =>
total +
line.reduce(
(lineTotal, run) =>
lineTotal + Array.from(run.text).filter((character) => !/\s/u.test(character)).length,
0,
),
0,
)
const gradient = interpolateGradient(colors, uncoloredCharacterCount)
let colorIndex = 0
const characters: GradientCharacter[] = []
safeDocument.lines.forEach((line, lineIndex) => {
line.forEach((run) => {
for (const character of Array.from(run.text)) {
const color = /\s/u.test(character) ? null : (gradient[colorIndex++] ?? null)
characters.push({ character, color, formats: run.formats })
}
})
if (lineIndex < safeDocument.lines.length - 1) {
characters.push({ character: '\n', color: null, formats: [], newline: true })
}
})
return characters
}
export function generateGradientOutput(
document: GradientTextDocument,
colors: string[],
adapterId: GradientFormatId,
options: GradientOutputOptions,
): string {
const characters = buildGradientCharacters(document, colors)
switch (adapterId) {
case 'vanilla':
return formatVanillaCharacters(characters, options.vanillaCharacter)
case 'vanilla-compatible':
return formatVanillaCompatibleCharacters(characters, options.vanillaCharacter)
case 'standard':
return formatEachCharacter(characters, (character) =>
character.color ? `${character.color}${character.character}` : character.character,
)
case 'cmi':
return formatEachCharacter(
characters,
(character) =>
character.color ? `{${character.color}}${character.character}` : character.character,
ampersandFormats,
)
case 'minimessage':
return formatEachCharacter(
characters,
(character) =>
character.color ? `<${character.color}>${character.character}` : character.character,
minimessageFormats,
)
case 'minimessage-gradient':
return formatGradientRuns(
characters,
colors,
options.simplifyGradients,
(text, runColors, formats) =>
wrapFormats(
`<gradient:${runColors.join(':')}>${text}</gradient>`,
formats,
minimessageFormats,
),
)
case 'minedown':
return formatEachCharacter(
characters,
(character) =>
character.color ? `&${character.color}&${character.character}` : character.character,
minedownFormats,
)
case 'snbt':
return toSnbt(characters)
case 'trchat':
case 'taboolib':
return formatEachCharacter(
characters,
(character) =>
character.color ? `&{${character.color}}${character.character}` : character.character,
ampersandFormats,
)
case 'taboolib-gradient':
return formatGradientRuns(
characters,
colors,
options.simplifyGradients,
(text, runColors, formats) => {
const modifiers = formats.map((format) => taboolibFormats[format]).filter(Boolean)
return `[${text}](gradient=${runColors.join(',')}${modifiers.length ? `;${modifiers.join(';')}` : ''})`
},
)
case 'rosegarden-gradient':
return formatGradientRuns(
characters,
colors,
options.simplifyGradients,
(text, runColors, formats) =>
wrapFormats(`<g:${runColors.join(':')}>${text}`, formats, minimessageFormats),
)
case 'chat-colors':
return formatEachCharacter(
characters,
(character) =>
character.color ? `[${character.color}]${character.character}` : character.character,
chatColorsFormats,
)
case 'motd':
return formatMotdCharacters(characters)
case 'bbcode':
return formatEachCharacter(
characters,
(character) =>
character.color
? `[color=${character.color}]${character.character}[/color]`
: character.character,
bbcodeFormats,
)
case 'json':
return JSON.stringify(toTextComponents(characters))
case 'html':
return toHtml(characters)
case 'csv':
return toCsv(characters)
case 'terraria':
return formatEachCharacter(characters, (character) =>
character.color
? `[c/${character.color.slice(1)}:${character.character}]`
: character.character,
)
}
}
export function normalizeGradientDocument(value: GradientTextDocument): GradientTextDocument {
const lines = Array.isArray(value?.lines) ? value.lines : []
const normalizedLines = lines.map((line) => {
if (!Array.isArray(line)) return []
return line
.filter((run): run is GradientTextRun => typeof run?.text === 'string')
.map((run) => ({
text: run.text,
formats: TEXT_FORMATS.filter(
(format) => Array.isArray(run.formats) && run.formats.includes(format),
),
}))
})
return { lines: normalizedLines.length ? normalizedLines : [[{ text: '', formats: [] }]] }
}
const minimessageFormats: Record<TextFormat, readonly [string, string]> = {
bold: ['<b>', '</b>'],
italic: ['<i>', '</i>'],
underlined: ['<u>', '</u>'],
strikethrough: ['<st>', '</st>'],
obfuscated: ['<obf>', '</obf>'],
}
const ampersandFormats: Record<TextFormat, readonly [string, string]> = {
bold: ['&l', ''],
italic: ['&o', ''],
underlined: ['&n', ''],
strikethrough: ['&m', ''],
obfuscated: ['&k', ''],
}
const minedownFormats: Record<TextFormat, readonly [string, string]> = {
bold: ['**', '**'],
italic: ['##', '##'],
underlined: ['__', '__'],
strikethrough: ['~~', '~~'],
obfuscated: ['??', '??'],
}
const chatColorsFormats: Record<TextFormat, readonly [string, string]> = {
bold: ['**', '**'],
italic: ['*', '*'],
underlined: ['__', '__'],
strikethrough: ['~~', '~~'],
obfuscated: ['', ''],
}
const bbcodeFormats: Record<TextFormat, readonly [string, string]> = {
bold: ['[b]', '[/b]'],
italic: ['[i]', '[/i]'],
underlined: ['[u]', '[/u]'],
strikethrough: ['[s]', '[/s]'],
obfuscated: ['', ''],
}
const taboolibFormats: Record<TextFormat, string> = {
bold: 'b',
italic: 'i',
underlined: 'u',
strikethrough: 's',
obfuscated: 'o',
}
function formatEachCharacter(
characters: GradientCharacter[],
formatter: (character: GradientCharacter) => string,
formatMap?: Record<TextFormat, readonly [string, string]>,
): string {
return characters
.map((character) => {
if (character.newline) return '\n'
const formatted = formatter(character)
return formatMap ? wrapFormats(formatted, character.formats, formatMap) : formatted
})
.join('')
}
function formatLegacyCharacters(
characters: GradientCharacter[],
formatter: (character: GradientCharacter) => string,
): string {
return characters
.map((character) => {
if (character.newline) return '\n'
return formatter(character)
})
.join('')
}
function formatVanillaCharacters(
characters: GradientCharacter[],
characterCode: '&' | '§',
): string {
return formatLegacyCharacters(characters, (character) => {
if (!character.color) return character.character
return `${characterCode}${character.color}${legacyFormatCodes(character.formats, characterCode)}${character.character}`
})
}
function formatVanillaCompatibleCharacters(
characters: GradientCharacter[],
characterCode: '&' | '§',
): string {
return formatLegacyCharacters(characters, (character) => {
if (!character.color) return character.character
const hexadecimal = character.color
.slice(1)
.split('')
.map((part) => `${characterCode}${part}`)
.join('')
return `${characterCode}x${hexadecimal}${legacyFormatCodes(character.formats, characterCode)}${character.character}`
})
}
function formatMotdCharacters(characters: GradientCharacter[]): string {
const characterCode = '\\u00A7'
return formatLegacyCharacters(characters, (character) => {
if (!character.color) return character.character
const hexadecimal = character.color
.slice(1)
.split('')
.map((part) => `${characterCode}${part}`)
.join('')
return `${characterCode}x${hexadecimal}${legacyFormatCodes(character.formats, characterCode)}${character.character}`
})
}
function legacyFormatCodes(formats: TextFormat[], characterCode: string): string {
return formats
.map((format) => {
switch (format) {
case 'bold':
return `${characterCode}l`
case 'italic':
return `${characterCode}o`
case 'underlined':
return `${characterCode}n`
case 'strikethrough':
return `${characterCode}m`
case 'obfuscated':
return `${characterCode}k`
}
})
.join('')
}
function formatGradientRuns(
characters: GradientCharacter[],
baseColors: string[],
simplify: boolean,
formatter: (text: string, colors: string[], formats: TextFormat[]) => string,
): string {
const output: string[] = []
let run: GradientCharacter[] = []
const flush = () => {
if (!run.length) return
const text = run.map((character) => character.character).join('')
const runColors = run
.map((character) => character.color)
.filter((color): color is string => color !== null)
const colors =
simplify && runColors.length > 1 ? [runColors[0], runColors[runColors.length - 1]] : runColors
output.push(formatter(text, colors.length ? colors : baseColors, run[0].formats))
run = []
}
for (const character of characters) {
if (character.newline) {
flush()
output.push('\n')
continue
}
if (
run.length &&
(run[0].formats.join(',') !== character.formats.join(',') || character.color === null)
) {
flush()
}
run.push(character)
}
flush()
return output.join('')
}
function wrapFormats(
text: string,
formats: TextFormat[],
formatMap: Record<TextFormat, readonly [string, string]>,
): string {
return formats.reduce((result, format) => {
const [start, end] = formatMap[format]
return `${start}${result}${end}`
}, text)
}
function toTextComponents(characters: GradientCharacter[]) {
return characters.map((character) => {
const component: Record<string, string | boolean> = { text: character.character }
if (character.color) component.color = character.color
for (const format of character.formats) component[format] = true
return component
})
}
function toSnbt(characters: GradientCharacter[]): string {
return `[${characters
.map((character) => {
const entries = [`text:${quoteSnbt(character.character)}`]
if (character.color) entries.push(`color:${quoteSnbt(character.color)}`)
for (const format of character.formats) entries.push(`${format}:true`)
return `{${entries.join(',')}}`
})
.join(',')}]`
}
function toHtml(characters: GradientCharacter[]): string {
const lines: string[] = ['']
for (const character of characters) {
if (character.newline) {
lines.push('')
continue
}
const content = character.character === ' ' ? '&nbsp;' : escapeHtml(character.character)
const colored = character.color
? `<span style="color: ${character.color};">${content}</span>`
: content
lines[lines.length - 1] += wrapFormats(colored, character.formats, htmlFormats)
}
return lines.map((line) => `<p>${line}</p>`).join('')
}
const htmlFormats: Record<TextFormat, readonly [string, string]> = {
bold: ['<b>', '</b>'],
italic: ['<i>', '</i>'],
underlined: ['<u>', '</u>'],
strikethrough: ['<s>', '</s>'],
obfuscated: ['', ''],
}
function toCsv(characters: GradientCharacter[]): string {
const header = 'color,char,bold,italic,underlined,strikethrough,obfuscated'
const rows = characters.map((character) =>
[
character.color ?? '',
character.newline ? '\\n' : character.character,
...TEXT_FORMATS.map((format) => character.formats.includes(format)),
]
.map(csvCell)
.join(','),
)
return [header, ...rows].join('\n')
}
function csvCell(value: string | boolean): string {
const text = String(value)
return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text
}
function quoteSnbt(value: string): string {
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
}
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
function hexToColor(hex: string): Color {
const value = hex.slice(1)
return {
red: Number.parseInt(value.slice(0, 2), 16),
green: Number.parseInt(value.slice(2, 4), 16),
blue: Number.parseInt(value.slice(4, 6), 16),
}
}
function colorToHex(color: Color): string {
return `#${[color.red, color.green, color.blue]
.map((component) => Math.max(0, Math.min(255, component)).toString(16).padStart(2, '0'))
.join('')
.toUpperCase()}`
}

View File

@ -0,0 +1,70 @@
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import type {
ModTranslationAnalysis,
ModTranslationOptions,
ModTranslationTaskEvent,
ModTranslationTaskSnapshot,
} from './types.ts'
export { modTranslationPercent } from './job-state.ts'
const TASK_EVENT = 'mod-translation-task-event'
export async function analyzeMod(inputPath: string): Promise<ModTranslationAnalysis> {
return await invoke<ModTranslationAnalysis>('plugin:mod-translation|mod_translation_analyze', {
inputPath,
})
}
export async function translateMod(request: {
inputPath: string
outputPath: string
providerId: string
modelId: string
analysisId?: string
inputHash?: string
options: ModTranslationOptions
}): Promise<ModTranslationTaskSnapshot> {
return await invoke<ModTranslationTaskSnapshot>(
'plugin:mod-translation|mod_translation_translate',
{
inputPath: request.inputPath,
outputPath: request.outputPath,
providerId: request.providerId,
modelId: request.modelId,
analysisId: request.analysisId,
inputHash: request.inputHash,
options: request.options,
},
)
}
export async function cancelModTranslation(taskId: string): Promise<void> {
await invoke('plugin:mod-translation|mod_translation_cancel', { taskId })
}
export async function listModTranslationTasks(): Promise<ModTranslationTaskSnapshot[]> {
return await invoke<ModTranslationTaskSnapshot[]>(
'plugin:mod-translation|mod_translation_list_tasks',
)
}
export async function getModTranslationTask(
taskId: string,
): Promise<ModTranslationTaskSnapshot | null> {
return await invoke<ModTranslationTaskSnapshot | null>(
'plugin:mod-translation|mod_translation_get_task',
{ taskId },
)
}
export async function dismissModTranslationTask(taskId: string): Promise<void> {
await invoke('plugin:mod-translation|mod_translation_dismiss_task', { taskId })
}
export async function listenToModTranslationTasks(
handler: (event: ModTranslationTaskEvent) => void,
): Promise<UnlistenFn> {
return await listen<ModTranslationTaskEvent>(TASK_EVENT, (event) => handler(event.payload))
}

View File

@ -0,0 +1,207 @@
import {
BracesIcon,
FileArchiveIcon,
LanguagesIcon,
PackageIcon,
SearchIcon,
ShieldCheckIcon,
WrenchIcon,
} from '@modrinth/assets'
import { defineMessages } from '@modrinth/ui'
import type { Component } from 'vue'
import type { ModTranslationPhase } from './types.ts'
export const modTranslationMessages = defineMessages({
title: { id: 'app.lab.mod-translation.title', defaultMessage: 'Mod translation' },
description: {
id: 'app.lab.mod-translation.description',
defaultMessage: 'Translate any Minecraft mod JAR into Simplified Chinese.',
},
inputSection: {
id: 'app.lab.mod-translation.input-section',
defaultMessage: 'Input',
},
aiSection: {
id: 'app.lab.mod-translation.ai-section',
defaultMessage: 'AI and options',
},
jobsSection: {
id: 'app.lab.mod-translation.jobs-section',
defaultMessage: 'Jobs',
},
startHint: {
id: 'app.lab.mod-translation.start-hint',
defaultMessage: 'Choose a JAR and an AI model to start.',
},
outputPath: {
id: 'app.lab.mod-translation.output-path',
defaultMessage: 'Output: {path}',
},
selectFile: {
id: 'app.lab.mod-translation.select-file',
defaultMessage: 'Choose a mod JAR',
},
analyze: {
id: 'app.lab.mod-translation.analyze',
defaultMessage: 'Analyze',
},
analyzing: {
id: 'app.lab.mod-translation.analyzing',
defaultMessage: 'Analyzing…',
},
analyzingElapsed: {
id: 'app.lab.mod-translation.analyzing-elapsed',
defaultMessage: 'Analyzing for {seconds}s',
},
analyzingHint: {
id: 'app.lab.mod-translation.analyzing-hint',
defaultMessage: 'Local inspection: unpacking and scanning the mod. No AI tokens are consumed.',
},
analysis: {
id: 'app.lab.mod-translation.analysis',
defaultMessage: 'Analysis',
},
loader: {
id: 'app.lab.mod-translation.loader',
defaultMessage: 'Loader',
},
languageEntries: {
id: 'app.lab.mod-translation.language-entries',
defaultMessage: 'Language entries',
},
languageCharacters: {
id: 'app.lab.mod-translation.language-characters',
defaultMessage: 'Characters',
},
classCandidates: {
id: 'app.lab.mod-translation.class-candidates',
defaultMessage: 'Class text candidates',
},
estimatedQuote: {
id: 'app.lab.mod-translation.estimated-quote',
defaultMessage: 'Estimated usage',
},
estimatedTokens: {
id: 'app.lab.mod-translation.estimated-tokens',
defaultMessage: '{tokens} tokens',
},
estimatedTokensDetail: {
id: 'app.lab.mod-translation.estimated-tokens-detail',
defaultMessage: '~{calls} calls · {input} in / {output} out',
},
points: {
id: 'app.lab.mod-translation.points',
defaultMessage: '{points} points',
},
provider: {
id: 'app.lab.mod-translation.provider',
defaultMessage: 'AI provider',
},
model: {
id: 'app.lab.mod-translation.model',
defaultMessage: 'Text model',
},
aiNotConfigured: {
id: 'app.lab.mod-translation.ai-not-configured',
defaultMessage: 'AI is not configured. Open the AI settings to enable a provider and model.',
},
options: {
id: 'app.lab.mod-translation.options',
defaultMessage: 'Options',
},
batchSize: {
id: 'app.lab.mod-translation.batch-size',
defaultMessage: 'Batch size',
},
start: {
id: 'app.lab.mod-translation.start',
defaultMessage: 'Start translation',
},
cancel: {
id: 'app.lab.mod-translation.cancel',
defaultMessage: 'Cancel',
},
cancelling: {
id: 'app.lab.mod-translation.cancelling',
defaultMessage: 'Cancelling…',
},
noJobs: {
id: 'app.lab.mod-translation.no-jobs',
defaultMessage: 'Started jobs will appear here.',
},
openOutput: {
id: 'app.lab.mod-translation.open-output',
defaultMessage: 'Open output folder',
},
done: {
id: 'app.lab.mod-translation.done',
defaultMessage: 'Done',
},
failed: {
id: 'app.lab.mod-translation.failed',
defaultMessage: 'Failed',
},
signedMod: {
id: 'app.lab.mod-translation.signed-mod',
defaultMessage: 'This mod is signed and cannot be modified.',
},
phasePrepare: {
id: 'app.lab.mod-translation.phase.prepare',
defaultMessage: 'Preparing',
},
phaseResearch: {
id: 'app.lab.mod-translation.phase.research',
defaultMessage: 'Name generation',
},
phaseLanguage: {
id: 'app.lab.mod-translation.phase.language',
defaultMessage: 'Language',
},
phaseRepair: {
id: 'app.lab.mod-translation.phase.repair',
defaultMessage: 'Quality check',
},
phaseClass: {
id: 'app.lab.mod-translation.phase.class',
defaultMessage: 'Class text',
},
phaseValidation: {
id: 'app.lab.mod-translation.phase.validation',
defaultMessage: 'Validation',
},
phasePackaging: {
id: 'app.lab.mod-translation.phase.packaging',
defaultMessage: 'Packaging',
},
operationFailed: {
id: 'app.lab.mod-translation.operation-failed',
defaultMessage: 'The mod translation operation failed.',
},
languageSources: {
id: 'app.lab.mod-translation.language-sources',
defaultMessage: 'Language sources',
},
preparing: {
id: 'app.lab.mod-translation.preparing',
defaultMessage: 'Preparing…',
},
backgroundRunning: {
id: 'app.lab.mod-translation.background-running',
defaultMessage: 'Running in background',
},
})
export const modTranslationPhaseSteps: readonly {
id: ModTranslationPhase
icon: Component
label: (typeof modTranslationMessages)[keyof typeof modTranslationMessages]
}[] = [
{ id: 'prepare', icon: FileArchiveIcon, label: modTranslationMessages.phasePrepare },
{ id: 'research', icon: SearchIcon, label: modTranslationMessages.phaseResearch },
{ id: 'language', icon: LanguagesIcon, label: modTranslationMessages.phaseLanguage },
{ id: 'repair', icon: WrenchIcon, label: modTranslationMessages.phaseRepair },
{ id: 'class', icon: BracesIcon, label: modTranslationMessages.phaseClass },
{ id: 'validation', icon: ShieldCheckIcon, label: modTranslationMessages.phaseValidation },
{ id: 'packaging', icon: PackageIcon, label: modTranslationMessages.phasePackaging },
]

View File

@ -0,0 +1,202 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
countModTranslationJobs,
jobFromSnapshot,
modTranslationPercent,
reduceModTranslationJob,
replayContiguousTaskEvents,
} from './job-state.ts'
import { mapTaskEventsToTimeline } from './timeline.ts'
import type {
ModTranslationProgress,
ModTranslationTaskEvent,
ModTranslationTaskSnapshot,
} from './types.ts'
function progress(overrides: Partial<ModTranslationProgress> = {}): ModTranslationProgress {
return {
taskId: 'TASK-1',
phase: 'language',
message: 'translated',
completed: 10,
total: 100,
weightVerified: 10,
weightTotal: 100,
level: 'info',
finished: false,
ok: false,
...overrides,
}
}
function event(
sequence: number,
overrides: Partial<ModTranslationTaskEvent> = {},
): ModTranslationTaskEvent {
return {
eventId: `EVENT-${sequence}`,
taskId: 'TASK-1',
sequence,
occurredAt: `2026-08-07T00:00:0${sequence}Z`,
eventType: 'progress',
status: 'running',
progress: progress(),
...overrides,
}
}
function snapshot(events: ModTranslationTaskEvent[] = []): ModTranslationTaskSnapshot {
return {
taskId: 'TASK-1',
inputPath: 'C:/mods/demo.jar',
outputPath: 'C:/mods/demo-zh_cn.jar',
inputHash: 'abc',
startedAt: '2026-08-07T00:00:00Z',
updatedAt: '2026-08-07T00:00:00Z',
status: 'running',
sequence: events.at(-1)?.sequence ?? 0,
activities: [],
events,
}
}
test('percent uses verified weight and forces successful completion to 100', () => {
assert.equal(modTranslationPercent(progress({ weightVerified: 50, weightTotal: 100 })), 50)
assert.equal(modTranslationPercent(progress({ weightVerified: 100, weightTotal: 100 })), 99)
assert.equal(
modTranslationPercent(
progress({ weightVerified: 20, weightTotal: 100, finished: true, ok: true }),
),
100,
)
})
test('snapshot replays ordered events', () => {
const job = jobFromSnapshot(
snapshot([
event(1, { progress: progress({ phase: 'prepare', message: 'prepare' }) }),
event(2, { progress: progress({ phase: 'language', message: 'language' }) }),
]),
)
assert.equal(job.lastSequence, 2)
assert.equal(job.phase, 'language')
assert.equal(job.timeline.length, 2)
})
test('duplicate and stale events are ignored', () => {
const initial = jobFromSnapshot(snapshot([event(1)]))
assert.strictEqual(reduceModTranslationJob(initial, event(1)), initial)
assert.strictEqual(reduceModTranslationJob(initial, event(0)), initial)
})
test('out-of-order events wait for the missing sequence and then replay in order', () => {
const initial = jobFromSnapshot(snapshot())
const first = replayContiguousTaskEvents(initial, [
event(2, { progress: progress({ message: 'second' }) }),
])
assert.equal(first.job.lastSequence, 0)
assert.equal(first.pending.length, 1)
const second = replayContiguousTaskEvents(first.job, [
...first.pending,
event(1, { progress: progress({ message: 'first' }) }),
])
assert.equal(second.job.lastSequence, 2)
assert.equal(second.job.message, 'second')
assert.deepEqual(second.pending, [])
})
test('phase never regresses', () => {
const initial = jobFromSnapshot(
snapshot([event(1, { progress: progress({ phase: 'validation' }) })]),
)
const next = reduceModTranslationJob(
initial,
event(2, { progress: progress({ phase: 'language' }) }),
)
assert.equal(next.phase, 'validation')
})
test('zero-weight phase events preserve the last measurable progress', () => {
const initial = jobFromSnapshot(
snapshot([
event(1, {
progress: progress({ completed: 6, total: 21, weightVerified: 6, weightTotal: 25 }),
}),
]),
)
const next = reduceModTranslationJob(
initial,
event(2, {
progress: progress({
phase: 'repair',
message: 'starting validation',
completed: 0,
total: 0,
weightVerified: 0,
weightTotal: 0,
}),
}),
)
assert.equal(next.percent, 24)
assert.equal(next.completed, 6)
assert.equal(next.total, 21)
assert.equal(next.weightVerified, 6)
assert.equal(next.weightTotal, 25)
})
test('job counts distinguish failed tasks from successful completion', () => {
assert.deepEqual(
countModTranslationJobs([{ status: 'completed' }, { status: 'failed' }, { status: 'running' }]),
{ running: 1, completed: 1, failed: 1 },
)
})
test('failed packaging signal preserves the actual working phase and error', () => {
const initial = jobFromSnapshot(snapshot([event(1, { progress: progress({ phase: 'repair' }) })]))
const next = reduceModTranslationJob(
initial,
event(2, {
status: 'failed',
progress: progress({ phase: 'packaging', finished: true, level: 'error' }),
error: { code: 'UNSUPPORTED_RESOURCE', message: 'unsupported path' },
}),
)
assert.equal(next.phase, 'repair')
assert.equal(next.error?.code, 'UNSUPPORTED_RESOURCE')
})
test('successful task is always shown as 100 percent', () => {
const next = reduceModTranslationJob(
jobFromSnapshot(snapshot()),
event(1, {
status: 'completed',
progress: progress({ finished: true, ok: true, weightVerified: 1, weightTotal: 10 }),
}),
)
assert.equal(next.percent, 100)
})
test('timeline groups typed repair activity without exposing raw JSON as the title', () => {
const entries = mapTaskEventsToTimeline([
event(1, {
eventType: 'activity',
progress: null,
activity: {
taskId: 'TASK-1',
pass: 1,
kind: 'diagnosis',
status: 'running',
title: '发现 16 个疑难项',
summary: '正在批量处理',
count: 16,
issueIds: ['a', 'b'],
debug: { request: { entries: [] } },
},
}),
])
assert.equal(entries[0].title, '发现 16 个疑难项')
assert.equal(entries[0].pass, 1)
assert.deepEqual(entries[0].debug, { request: { entries: [] } })
})

View File

@ -0,0 +1,169 @@
import { mapTaskEventsToTimeline } from './timeline.ts'
import type {
ModTranslationJob,
ModTranslationPhase,
ModTranslationProgress,
ModTranslationTaskEvent,
ModTranslationTaskSnapshot,
} from './types.ts'
export const MOD_TRANSLATION_PHASES: readonly ModTranslationPhase[] = [
'prepare',
'research',
'language',
'repair',
'class',
'validation',
'packaging',
]
export function phaseIndex(phase: ModTranslationPhase): number {
return MOD_TRANSLATION_PHASES.indexOf(phase)
}
export function countModTranslationJobs(jobs: ReadonlyArray<Pick<ModTranslationJob, 'status'>>): {
running: number
completed: number
failed: number
} {
return jobs.reduce(
(counts, job) => {
counts[job.status] += 1
return counts
},
{ running: 0, completed: 0, failed: 0 },
)
}
export function modTranslationPercent(
progress: Pick<ModTranslationProgress, 'weightVerified' | 'weightTotal' | 'finished' | 'ok'>,
): number {
if (progress.finished && progress.ok) return 100
if (progress.weightTotal <= 0) return 0
const measured = Math.round(Math.min(1, progress.weightVerified / progress.weightTotal) * 100)
return progress.finished ? measured : Math.min(99, measured)
}
function monotonicPhase(current: ModTranslationPhase, incoming: ModTranslationPhase) {
return phaseIndex(incoming) >= phaseIndex(current) ? incoming : current
}
function applyProgress(
job: ModTranslationJob,
progress: ModTranslationProgress,
): ModTranslationJob {
const failedPackagingSignal = progress.finished && !progress.ok && progress.phase === 'packaging'
const phase = failedPackagingSignal ? job.phase : monotonicPhase(job.phase, progress.phase)
const hasWorkWeight = progress.weightTotal > 0
const hasItemCount = progress.total > 0
const weightVerified = hasWorkWeight ? progress.weightVerified : job.weightVerified
const weightTotal = hasWorkWeight ? progress.weightTotal : job.weightTotal
return {
...job,
phase,
message: progress.message,
percent: modTranslationPercent({
weightVerified,
weightTotal,
finished: progress.finished,
ok: progress.ok,
}),
completed: hasItemCount ? progress.completed : job.completed,
total: hasItemCount ? progress.total : job.total,
weightVerified,
weightTotal,
sample: progress.sample ?? job.sample,
level: progress.level,
}
}
export function jobFromSnapshot(snapshot: ModTranslationTaskSnapshot): ModTranslationJob {
const base: ModTranslationJob = {
taskId: snapshot.taskId,
inputPath: snapshot.inputPath,
outputPath: snapshot.outputPath,
inputHash: snapshot.inputHash,
startedAt: snapshot.startedAt,
updatedAt: snapshot.updatedAt,
status: snapshot.status,
lastSequence: 0,
phase: 'prepare',
message: '',
percent: 0,
completed: 0,
total: 0,
weightVerified: 0,
weightTotal: 0,
level: 'info',
events: [],
timeline: [],
report: snapshot.report ?? undefined,
error: snapshot.error ?? undefined,
}
const events = [...snapshot.events].sort((left, right) => left.sequence - right.sequence)
let job = base
for (const event of events) job = reduceModTranslationJob(job, event)
if (!events.length && snapshot.progress) job = applyProgress(job, snapshot.progress)
job.status = snapshot.status
job.updatedAt = snapshot.updatedAt
job.lastSequence = Math.max(job.lastSequence, snapshot.sequence)
job.report = snapshot.report ?? job.report
job.error = snapshot.error ?? job.error
if (job.status === 'completed') job.percent = 100
return job
}
export function reduceModTranslationJob(
job: ModTranslationJob,
event: ModTranslationTaskEvent,
): ModTranslationJob {
if (event.sequence <= job.lastSequence) return job
let next: ModTranslationJob = {
...job,
status: event.status,
updatedAt: event.occurredAt,
lastSequence: event.sequence,
events: [...job.events, event]
.sort((left, right) => left.sequence - right.sequence)
.slice(-400),
report: event.report ?? job.report,
error: event.error ?? job.error,
}
if (event.progress) next = applyProgress(next, event.progress)
if (next.status === 'completed') next.percent = 100
next.timeline = mapTaskEventsToTimeline(next.events)
return next
}
export function replayContiguousTaskEvents(
job: ModTranslationJob,
events: ModTranslationTaskEvent[],
): { job: ModTranslationJob; pending: ModTranslationTaskEvent[] } {
let next = job
const pending: ModTranslationTaskEvent[] = []
for (const event of [...events].sort((left, right) => left.sequence - right.sequence)) {
if (event.sequence <= next.lastSequence) continue
if (event.sequence === next.lastSequence + 1) next = reduceModTranslationJob(next, event)
else pending.push(event)
}
return { job: next, pending }
}
export function mergeTaskSnapshot(
job: ModTranslationJob | undefined,
snapshot: ModTranslationTaskSnapshot,
): ModTranslationJob {
if (!job) return jobFromSnapshot(snapshot)
let next = job
for (const event of [...snapshot.events].sort((left, right) => left.sequence - right.sequence)) {
next = reduceModTranslationJob(next, event)
}
return {
...next,
status: snapshot.status,
updatedAt: snapshot.updatedAt,
report: snapshot.report ?? next.report,
error: snapshot.error ?? next.error,
percent: snapshot.status === 'completed' ? 100 : next.percent,
}
}

View File

@ -0,0 +1,63 @@
import type { ModTranslationTaskEvent, ModTranslationTimelineEntry } from './types.ts'
export function mapTaskEventsToTimeline(
events: ModTranslationTaskEvent[],
): ModTranslationTimelineEntry[] {
const timeline: ModTranslationTimelineEntry[] = []
for (const event of [...events].sort((left, right) => left.sequence - right.sequence)) {
if (event.activity) {
timeline.push({
id: event.eventId,
sequence: event.sequence,
time: event.occurredAt,
phase: 'repair',
pass: event.activity.pass,
kind: event.activity.kind,
status: event.activity.status,
title: event.activity.title,
summary: event.activity.summary,
count: event.activity.count,
issueIds: event.activity.issueIds,
debug: event.activity.debug,
})
continue
}
const progress = event.progress
if (!progress?.message) continue
const previous = timeline[timeline.length - 1]
if (
previous?.kind === 'progress' &&
previous.phase === progress.phase &&
previous.title === progress.message &&
progress.level === 'info'
) {
continue
}
timeline.push({
id: event.eventId,
sequence: event.sequence,
time: event.occurredAt,
phase: progress.phase,
kind: 'progress',
status: progress.finished ? (progress.ok ? 'success' : 'error') : progress.level,
title: progress.message,
issueIds: [],
})
}
return timeline.slice(-200)
}
export function groupTimelineByRepairPass(entries: ModTranslationTimelineEntry[]) {
const groups: Array<{
id: string
pass?: number
entries: ModTranslationTimelineEntry[]
}> = []
for (const entry of entries) {
const id = entry.pass ? `pass-${entry.pass}` : `phase-${entry.phase}`
const last = groups[groups.length - 1]
if (last?.id === id) last.entries.push(entry)
else groups.push({ id, pass: entry.pass, entries: [entry] })
}
return groups
}

View File

@ -0,0 +1,182 @@
export type ModTranslationPhase =
| 'prepare'
| 'research'
| 'language'
| 'repair'
| 'class'
| 'validation'
| 'packaging'
export type ModTranslationLevel = 'info' | 'warn' | 'error'
export type ModTranslationTaskStatus = 'running' | 'completed' | 'failed'
export interface ModTranslationQuote {
estimatedInputTokens: number
estimatedOutputTokens: number
estimatedTokens: number
estimatedCalls: number
languageBatches: number
classBatches: number
points: number
characters: number
entries: number
}
export interface ModTranslationLanguageSourceSummary {
kind: string
namespace: string
sourcePath: string
targetPath: string
entries: number
characters: number
required: number
}
export interface ModTranslationClassCandidateSummary {
id: string
path: string
text: string
occurrences: number
}
export interface ModTranslationAnalysis {
analysisId: string
inputHash: string
loader: string
modIds: string[]
projectNames: string[]
modVersion?: string
minecraftVersionRange?: string
signed: boolean
warnings: string[]
languageSources: ModTranslationLanguageSourceSummary[]
languageEntries: number
languageCharacters: number
requiredEntries: number
classCandidates: ModTranslationClassCandidateSummary[]
quote: ModTranslationQuote
}
export interface ModTranslationProgress {
taskId: string
phase: ModTranslationPhase
message: string
completed: number
total: number
weightVerified: number
weightTotal: number
sample?: { source: string; translation: string } | null
level: ModTranslationLevel
finished: boolean
ok: boolean
report?: string | null
}
export interface ModTranslationActivity {
taskId: string
pass: number
kind: string
status: string
title: string
summary: string
count: number
issueIds: string[]
debug?: unknown
}
export interface ModTranslationFailure {
code: string
message: string
details?: unknown
}
export interface ModTranslationReport {
taskId: string
ok: boolean
outputPath: string
modName?: { name?: string; source?: string } | null
languageAttempted: number
languageAccepted: number
classResolved: number
classTotal: number
classChangedFiles?: string[]
warnings: string[]
error?: string | null
}
export interface ModTranslationTaskEvent {
eventId: string
taskId: string
sequence: number
occurredAt: string
eventType: 'progress' | 'activity' | 'finished'
status: ModTranslationTaskStatus
progress?: ModTranslationProgress | null
activity?: ModTranslationActivity | null
report?: ModTranslationReport | null
error?: ModTranslationFailure | null
}
export interface ModTranslationTaskSnapshot {
taskId: string
inputPath: string
outputPath: string
inputHash: string
startedAt: string
updatedAt: string
status: ModTranslationTaskStatus
sequence: number
progress?: ModTranslationProgress | null
activities: ModTranslationActivity[]
report?: ModTranslationReport | null
error?: ModTranslationFailure | null
events: ModTranslationTaskEvent[]
}
export interface ModTranslationOptions {
batchSize: number
deepBatchSize: number
generateModName: boolean
repairEnabled: boolean
classTextEnabled: boolean
maxClassBatch: number
}
export interface ModTranslationTimelineEntry {
id: string
sequence: number
time: string
phase: ModTranslationPhase
pass?: number
kind: string
status: string
title: string
summary?: string
count?: number
issueIds: string[]
debug?: unknown
}
export interface ModTranslationJob {
taskId: string
inputPath: string
outputPath: string
inputHash: string
startedAt: string
updatedAt: string
status: ModTranslationTaskStatus
lastSequence: number
phase: ModTranslationPhase
message: string
percent: number
completed: number
total: number
weightVerified: number
weightTotal: number
sample?: { source: string; translation: string }
level: ModTranslationLevel
events: ModTranslationTaskEvent[]
timeline: ModTranslationTimelineEntry[]
report?: ModTranslationReport
error?: ModTranslationFailure
}

View File

@ -0,0 +1,7 @@
# Recipe generator third-party notices
- Vanilla item tags: [destruc7i0n/crafting](https://github.com/destruc7i0n/crafting), commit `e6c71dd816216a73cda2787aa5253f641b57fbeb`, MIT License.
- Item metadata and textures: [destruc7i0n/minecraft-textures](https://github.com/destruc7i0n/minecraft-textures), npm `26.2.1`, GPL-3.0, author TheDestruc7i0n.
- Minecraft and its original resources are Copyright Mojang Studios / Microsoft and are used only to identify compatible game content. Axolotl Launcher is not affiliated with or endorsed by Mojang Studios or Microsoft.
License texts: [MIT.txt](../../../../third-party/licenses/MIT.txt), [app-frontend LICENSE](../../../LICENSE).

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,405 @@
{
"minecraft:acacia_logs": [
"minecraft:acacia_log",
"minecraft:acacia_wood",
"minecraft:stripped_acacia_log",
"minecraft:stripped_acacia_wood"
],
"minecraft:anvil": ["minecraft:anvil", "minecraft:chipped_anvil", "minecraft:damaged_anvil"],
"minecraft:arrows": ["minecraft:arrow", "minecraft:tipped_arrow", "minecraft:spectral_arrow"],
"minecraft:banners": [
"minecraft:white_banner",
"minecraft:orange_banner",
"minecraft:magenta_banner",
"minecraft:light_blue_banner",
"minecraft:yellow_banner",
"minecraft:lime_banner",
"minecraft:pink_banner",
"minecraft:gray_banner",
"minecraft:light_gray_banner",
"minecraft:cyan_banner",
"minecraft:purple_banner",
"minecraft:blue_banner",
"minecraft:brown_banner",
"minecraft:green_banner",
"minecraft:red_banner",
"minecraft:black_banner"
],
"minecraft:beds": [
"minecraft:red_bed",
"minecraft:black_bed",
"minecraft:blue_bed",
"minecraft:brown_bed",
"minecraft:cyan_bed",
"minecraft:gray_bed",
"minecraft:green_bed",
"minecraft:light_blue_bed",
"minecraft:light_gray_bed",
"minecraft:lime_bed",
"minecraft:magenta_bed",
"minecraft:orange_bed",
"minecraft:pink_bed",
"minecraft:purple_bed",
"minecraft:white_bed",
"minecraft:yellow_bed"
],
"minecraft:birch_logs": [
"minecraft:birch_log",
"minecraft:birch_wood",
"minecraft:stripped_birch_log",
"minecraft:stripped_birch_wood"
],
"minecraft:boats": [
"minecraft:oak_boat",
"minecraft:spruce_boat",
"minecraft:birch_boat",
"minecraft:jungle_boat",
"minecraft:acacia_boat",
"minecraft:dark_oak_boat"
],
"minecraft:buttons": [
"minecraft:oak_button",
"minecraft:spruce_button",
"minecraft:birch_button",
"minecraft:jungle_button",
"minecraft:acacia_button",
"minecraft:dark_oak_button",
"minecraft:stone_button"
],
"minecraft:carpets": [
"minecraft:white_carpet",
"minecraft:orange_carpet",
"minecraft:magenta_carpet",
"minecraft:light_blue_carpet",
"minecraft:yellow_carpet",
"minecraft:lime_carpet",
"minecraft:pink_carpet",
"minecraft:gray_carpet",
"minecraft:light_gray_carpet",
"minecraft:cyan_carpet",
"minecraft:purple_carpet",
"minecraft:blue_carpet",
"minecraft:brown_carpet",
"minecraft:green_carpet",
"minecraft:red_carpet",
"minecraft:black_carpet"
],
"minecraft:coals": ["minecraft:coal", "minecraft:charcoal"],
"minecraft:dark_oak_logs": [
"minecraft:dark_oak_log",
"minecraft:dark_oak_wood",
"minecraft:stripped_dark_oak_log",
"minecraft:stripped_dark_oak_wood"
],
"minecraft:doors": [
"minecraft:oak_door",
"minecraft:spruce_door",
"minecraft:birch_door",
"minecraft:jungle_door",
"minecraft:acacia_door",
"minecraft:dark_oak_door",
"minecraft:iron_door"
],
"minecraft:fences": [
"minecraft:oak_fence",
"minecraft:acacia_fence",
"minecraft:dark_oak_fence",
"minecraft:spruce_fence",
"minecraft:birch_fence",
"minecraft:jungle_fence",
"minecraft:nether_brick_fence"
],
"minecraft:fishes": [
"minecraft:cod",
"minecraft:cooked_cod",
"minecraft:salmon",
"minecraft:cooked_salmon",
"minecraft:pufferfish",
"minecraft:tropical_fish"
],
"minecraft:jungle_logs": [
"minecraft:jungle_log",
"minecraft:jungle_wood",
"minecraft:stripped_jungle_log",
"minecraft:stripped_jungle_wood"
],
"minecraft:leaves": [
"minecraft:jungle_leaves",
"minecraft:oak_leaves",
"minecraft:spruce_leaves",
"minecraft:dark_oak_leaves",
"minecraft:acacia_leaves",
"minecraft:birch_leaves"
],
"minecraft:logs": [
"minecraft:dark_oak_log",
"minecraft:dark_oak_wood",
"minecraft:stripped_dark_oak_log",
"minecraft:stripped_dark_oak_wood",
"minecraft:oak_log",
"minecraft:oak_wood",
"minecraft:stripped_oak_log",
"minecraft:stripped_oak_wood",
"minecraft:acacia_log",
"minecraft:acacia_wood",
"minecraft:stripped_acacia_log",
"minecraft:stripped_acacia_wood",
"minecraft:birch_log",
"minecraft:birch_wood",
"minecraft:stripped_birch_log",
"minecraft:stripped_birch_wood",
"minecraft:jungle_log",
"minecraft:jungle_wood",
"minecraft:stripped_jungle_log",
"minecraft:stripped_jungle_wood",
"minecraft:spruce_log",
"minecraft:spruce_wood",
"minecraft:stripped_spruce_log",
"minecraft:stripped_spruce_wood"
],
"minecraft:music_discs": [
"minecraft:music_disc_13",
"minecraft:music_disc_cat",
"minecraft:music_disc_blocks",
"minecraft:music_disc_chirp",
"minecraft:music_disc_far",
"minecraft:music_disc_mall",
"minecraft:music_disc_mellohi",
"minecraft:music_disc_stal",
"minecraft:music_disc_strad",
"minecraft:music_disc_ward",
"minecraft:music_disc_11",
"minecraft:music_disc_wait"
],
"minecraft:oak_logs": [
"minecraft:oak_log",
"minecraft:oak_wood",
"minecraft:stripped_oak_log",
"minecraft:stripped_oak_wood"
],
"minecraft:planks": [
"minecraft:oak_planks",
"minecraft:spruce_planks",
"minecraft:birch_planks",
"minecraft:jungle_planks",
"minecraft:acacia_planks",
"minecraft:dark_oak_planks"
],
"minecraft:rails": [
"minecraft:rail",
"minecraft:powered_rail",
"minecraft:detector_rail",
"minecraft:activator_rail"
],
"minecraft:sand": ["minecraft:sand", "minecraft:red_sand"],
"minecraft:saplings": [
"minecraft:oak_sapling",
"minecraft:spruce_sapling",
"minecraft:birch_sapling",
"minecraft:jungle_sapling",
"minecraft:acacia_sapling",
"minecraft:dark_oak_sapling"
],
"minecraft:signs": [
"minecraft:oak_sign",
"minecraft:spruce_sign",
"minecraft:birch_sign",
"minecraft:acacia_sign",
"minecraft:jungle_sign",
"minecraft:dark_oak_sign"
],
"minecraft:slabs": [
"minecraft:stone_slab",
"minecraft:smooth_stone_slab",
"minecraft:stone_brick_slab",
"minecraft:sandstone_slab",
"minecraft:acacia_slab",
"minecraft:birch_slab",
"minecraft:dark_oak_slab",
"minecraft:jungle_slab",
"minecraft:oak_slab",
"minecraft:spruce_slab",
"minecraft:purpur_slab",
"minecraft:quartz_slab",
"minecraft:red_sandstone_slab",
"minecraft:brick_slab",
"minecraft:cobblestone_slab",
"minecraft:nether_brick_slab",
"minecraft:petrified_oak_slab",
"minecraft:prismarine_slab",
"minecraft:prismarine_brick_slab",
"minecraft:dark_prismarine_slab",
"minecraft:polished_granite_slab",
"minecraft:smooth_red_sandstone_slab",
"minecraft:mossy_stone_brick_slab",
"minecraft:polished_diorite_slab",
"minecraft:mossy_cobblestone_slab",
"minecraft:end_stone_brick_slab",
"minecraft:smooth_sandstone_slab",
"minecraft:smooth_quartz_slab",
"minecraft:granite_slab",
"minecraft:andesite_slab",
"minecraft:red_nether_brick_slab",
"minecraft:polished_andesite_slab",
"minecraft:diorite_slab"
],
"minecraft:small_flowers": [
"minecraft:dandelion",
"minecraft:poppy",
"minecraft:blue_orchid",
"minecraft:allium",
"minecraft:azure_bluet",
"minecraft:red_tulip",
"minecraft:orange_tulip",
"minecraft:white_tulip",
"minecraft:pink_tulip",
"minecraft:oxeye_daisy",
"minecraft:cornflower",
"minecraft:lily_of_the_valley",
"minecraft:wither_rose"
],
"minecraft:spruce_logs": [
"minecraft:spruce_log",
"minecraft:spruce_wood",
"minecraft:stripped_spruce_log",
"minecraft:stripped_spruce_wood"
],
"minecraft:stairs": [
"minecraft:oak_stairs",
"minecraft:cobblestone_stairs",
"minecraft:spruce_stairs",
"minecraft:sandstone_stairs",
"minecraft:acacia_stairs",
"minecraft:jungle_stairs",
"minecraft:birch_stairs",
"minecraft:dark_oak_stairs",
"minecraft:nether_brick_stairs",
"minecraft:stone_brick_stairs",
"minecraft:brick_stairs",
"minecraft:purpur_stairs",
"minecraft:quartz_stairs",
"minecraft:red_sandstone_stairs",
"minecraft:prismarine_brick_stairs",
"minecraft:prismarine_stairs",
"minecraft:dark_prismarine_stairs",
"minecraft:polished_granite_stairs",
"minecraft:smooth_red_sandstone_stairs",
"minecraft:mossy_stone_brick_stairs",
"minecraft:polished_diorite_stairs",
"minecraft:mossy_cobblestone_stairs",
"minecraft:end_stone_brick_stairs",
"minecraft:stone_stairs",
"minecraft:smooth_sandstone_stairs",
"minecraft:smooth_quartz_stairs",
"minecraft:granite_stairs",
"minecraft:andesite_stairs",
"minecraft:red_nether_brick_stairs",
"minecraft:polished_andesite_stairs",
"minecraft:diorite_stairs"
],
"minecraft:stone_bricks": [
"minecraft:stone_bricks",
"minecraft:mossy_stone_bricks",
"minecraft:cracked_stone_bricks",
"minecraft:chiseled_stone_bricks"
],
"minecraft:trapdoors": [
"minecraft:acacia_trapdoor",
"minecraft:birch_trapdoor",
"minecraft:dark_oak_trapdoor",
"minecraft:jungle_trapdoor",
"minecraft:oak_trapdoor",
"minecraft:spruce_trapdoor",
"minecraft:iron_trapdoor"
],
"minecraft:walls": [
"minecraft:cobblestone_wall",
"minecraft:mossy_cobblestone_wall",
"minecraft:brick_wall",
"minecraft:prismarine_wall",
"minecraft:red_sandstone_wall",
"minecraft:mossy_stone_brick_wall",
"minecraft:granite_wall",
"minecraft:stone_brick_wall",
"minecraft:nether_brick_wall",
"minecraft:andesite_wall",
"minecraft:red_nether_brick_wall",
"minecraft:sandstone_wall",
"minecraft:end_stone_brick_wall",
"minecraft:diorite_wall"
],
"minecraft:wooden_buttons": [
"minecraft:oak_button",
"minecraft:spruce_button",
"minecraft:birch_button",
"minecraft:jungle_button",
"minecraft:acacia_button",
"minecraft:dark_oak_button"
],
"minecraft:wooden_doors": [
"minecraft:oak_door",
"minecraft:spruce_door",
"minecraft:birch_door",
"minecraft:jungle_door",
"minecraft:acacia_door",
"minecraft:dark_oak_door"
],
"minecraft:wooden_fences": [
"minecraft:oak_fence",
"minecraft:acacia_fence",
"minecraft:dark_oak_fence",
"minecraft:spruce_fence",
"minecraft:birch_fence",
"minecraft:jungle_fence"
],
"minecraft:wooden_pressure_plates": [
"minecraft:oak_pressure_plate",
"minecraft:spruce_pressure_plate",
"minecraft:birch_pressure_plate",
"minecraft:jungle_pressure_plate",
"minecraft:acacia_pressure_plate",
"minecraft:dark_oak_pressure_plate"
],
"minecraft:wooden_slabs": [
"minecraft:oak_slab",
"minecraft:spruce_slab",
"minecraft:birch_slab",
"minecraft:jungle_slab",
"minecraft:acacia_slab",
"minecraft:dark_oak_slab"
],
"minecraft:wooden_stairs": [
"minecraft:oak_stairs",
"minecraft:spruce_stairs",
"minecraft:birch_stairs",
"minecraft:jungle_stairs",
"minecraft:acacia_stairs",
"minecraft:dark_oak_stairs"
],
"minecraft:wooden_trapdoors": [
"minecraft:acacia_trapdoor",
"minecraft:birch_trapdoor",
"minecraft:dark_oak_trapdoor",
"minecraft:jungle_trapdoor",
"minecraft:oak_trapdoor",
"minecraft:spruce_trapdoor"
],
"minecraft:wool": [
"minecraft:white_wool",
"minecraft:orange_wool",
"minecraft:magenta_wool",
"minecraft:light_blue_wool",
"minecraft:yellow_wool",
"minecraft:lime_wool",
"minecraft:pink_wool",
"minecraft:gray_wool",
"minecraft:light_gray_wool",
"minecraft:cyan_wool",
"minecraft:purple_wool",
"minecraft:blue_wool",
"minecraft:brown_wool",
"minecraft:green_wool",
"minecraft:red_wool",
"minecraft:black_wool"
]
}

View File

@ -0,0 +1,433 @@
{
"minecraft:acacia_logs": [
"minecraft:acacia_log",
"minecraft:acacia_wood",
"minecraft:stripped_acacia_log",
"minecraft:stripped_acacia_wood"
],
"minecraft:anvil": ["minecraft:anvil", "minecraft:chipped_anvil", "minecraft:damaged_anvil"],
"minecraft:arrows": ["minecraft:arrow", "minecraft:tipped_arrow", "minecraft:spectral_arrow"],
"minecraft:banners": [
"minecraft:white_banner",
"minecraft:orange_banner",
"minecraft:magenta_banner",
"minecraft:light_blue_banner",
"minecraft:yellow_banner",
"minecraft:lime_banner",
"minecraft:pink_banner",
"minecraft:gray_banner",
"minecraft:light_gray_banner",
"minecraft:cyan_banner",
"minecraft:purple_banner",
"minecraft:blue_banner",
"minecraft:brown_banner",
"minecraft:green_banner",
"minecraft:red_banner",
"minecraft:black_banner"
],
"minecraft:beds": [
"minecraft:red_bed",
"minecraft:black_bed",
"minecraft:blue_bed",
"minecraft:brown_bed",
"minecraft:cyan_bed",
"minecraft:gray_bed",
"minecraft:green_bed",
"minecraft:light_blue_bed",
"minecraft:light_gray_bed",
"minecraft:lime_bed",
"minecraft:magenta_bed",
"minecraft:orange_bed",
"minecraft:pink_bed",
"minecraft:purple_bed",
"minecraft:white_bed",
"minecraft:yellow_bed"
],
"minecraft:birch_logs": [
"minecraft:birch_log",
"minecraft:birch_wood",
"minecraft:stripped_birch_log",
"minecraft:stripped_birch_wood"
],
"minecraft:boats": [
"minecraft:oak_boat",
"minecraft:spruce_boat",
"minecraft:birch_boat",
"minecraft:jungle_boat",
"minecraft:acacia_boat",
"minecraft:dark_oak_boat"
],
"minecraft:buttons": [
"minecraft:oak_button",
"minecraft:spruce_button",
"minecraft:birch_button",
"minecraft:jungle_button",
"minecraft:acacia_button",
"minecraft:dark_oak_button",
"minecraft:stone_button"
],
"minecraft:carpets": [
"minecraft:white_carpet",
"minecraft:orange_carpet",
"minecraft:magenta_carpet",
"minecraft:light_blue_carpet",
"minecraft:yellow_carpet",
"minecraft:lime_carpet",
"minecraft:pink_carpet",
"minecraft:gray_carpet",
"minecraft:light_gray_carpet",
"minecraft:cyan_carpet",
"minecraft:purple_carpet",
"minecraft:blue_carpet",
"minecraft:brown_carpet",
"minecraft:green_carpet",
"minecraft:red_carpet",
"minecraft:black_carpet"
],
"minecraft:coals": ["minecraft:coal", "minecraft:charcoal"],
"minecraft:dark_oak_logs": [
"minecraft:dark_oak_log",
"minecraft:dark_oak_wood",
"minecraft:stripped_dark_oak_log",
"minecraft:stripped_dark_oak_wood"
],
"minecraft:doors": [
"minecraft:oak_door",
"minecraft:spruce_door",
"minecraft:birch_door",
"minecraft:jungle_door",
"minecraft:acacia_door",
"minecraft:dark_oak_door",
"minecraft:iron_door"
],
"minecraft:fences": [
"minecraft:oak_fence",
"minecraft:acacia_fence",
"minecraft:dark_oak_fence",
"minecraft:spruce_fence",
"minecraft:birch_fence",
"minecraft:jungle_fence",
"minecraft:nether_brick_fence"
],
"minecraft:fishes": [
"minecraft:cod",
"minecraft:cooked_cod",
"minecraft:salmon",
"minecraft:cooked_salmon",
"minecraft:pufferfish",
"minecraft:tropical_fish"
],
"minecraft:flowers": [
"minecraft:dandelion",
"minecraft:poppy",
"minecraft:blue_orchid",
"minecraft:allium",
"minecraft:azure_bluet",
"minecraft:red_tulip",
"minecraft:orange_tulip",
"minecraft:white_tulip",
"minecraft:pink_tulip",
"minecraft:oxeye_daisy",
"minecraft:cornflower",
"minecraft:lily_of_the_valley",
"minecraft:wither_rose",
"minecraft:sunflower",
"minecraft:lilac",
"minecraft:peony",
"minecraft:rose_bush"
],
"minecraft:jungle_logs": [
"minecraft:jungle_log",
"minecraft:jungle_wood",
"minecraft:stripped_jungle_log",
"minecraft:stripped_jungle_wood"
],
"minecraft:leaves": [
"minecraft:jungle_leaves",
"minecraft:oak_leaves",
"minecraft:spruce_leaves",
"minecraft:dark_oak_leaves",
"minecraft:acacia_leaves",
"minecraft:birch_leaves"
],
"minecraft:lectern_books": ["minecraft:written_book", "minecraft:writable_book"],
"minecraft:logs": [
"minecraft:dark_oak_log",
"minecraft:dark_oak_wood",
"minecraft:stripped_dark_oak_log",
"minecraft:stripped_dark_oak_wood",
"minecraft:oak_log",
"minecraft:oak_wood",
"minecraft:stripped_oak_log",
"minecraft:stripped_oak_wood",
"minecraft:acacia_log",
"minecraft:acacia_wood",
"minecraft:stripped_acacia_log",
"minecraft:stripped_acacia_wood",
"minecraft:birch_log",
"minecraft:birch_wood",
"minecraft:stripped_birch_log",
"minecraft:stripped_birch_wood",
"minecraft:jungle_log",
"minecraft:jungle_wood",
"minecraft:stripped_jungle_log",
"minecraft:stripped_jungle_wood",
"minecraft:spruce_log",
"minecraft:spruce_wood",
"minecraft:stripped_spruce_log",
"minecraft:stripped_spruce_wood"
],
"minecraft:music_discs": [
"minecraft:music_disc_13",
"minecraft:music_disc_cat",
"minecraft:music_disc_blocks",
"minecraft:music_disc_chirp",
"minecraft:music_disc_far",
"minecraft:music_disc_mall",
"minecraft:music_disc_mellohi",
"minecraft:music_disc_stal",
"minecraft:music_disc_strad",
"minecraft:music_disc_ward",
"minecraft:music_disc_11",
"minecraft:music_disc_wait"
],
"minecraft:oak_logs": [
"minecraft:oak_log",
"minecraft:oak_wood",
"minecraft:stripped_oak_log",
"minecraft:stripped_oak_wood"
],
"minecraft:planks": [
"minecraft:oak_planks",
"minecraft:spruce_planks",
"minecraft:birch_planks",
"minecraft:jungle_planks",
"minecraft:acacia_planks",
"minecraft:dark_oak_planks"
],
"minecraft:rails": [
"minecraft:rail",
"minecraft:powered_rail",
"minecraft:detector_rail",
"minecraft:activator_rail"
],
"minecraft:sand": ["minecraft:sand", "minecraft:red_sand"],
"minecraft:saplings": [
"minecraft:oak_sapling",
"minecraft:spruce_sapling",
"minecraft:birch_sapling",
"minecraft:jungle_sapling",
"minecraft:acacia_sapling",
"minecraft:dark_oak_sapling"
],
"minecraft:signs": [
"minecraft:oak_sign",
"minecraft:spruce_sign",
"minecraft:birch_sign",
"minecraft:acacia_sign",
"minecraft:jungle_sign",
"minecraft:dark_oak_sign"
],
"minecraft:slabs": [
"minecraft:stone_slab",
"minecraft:smooth_stone_slab",
"minecraft:stone_brick_slab",
"minecraft:sandstone_slab",
"minecraft:acacia_slab",
"minecraft:birch_slab",
"minecraft:dark_oak_slab",
"minecraft:jungle_slab",
"minecraft:oak_slab",
"minecraft:spruce_slab",
"minecraft:purpur_slab",
"minecraft:quartz_slab",
"minecraft:red_sandstone_slab",
"minecraft:brick_slab",
"minecraft:cobblestone_slab",
"minecraft:nether_brick_slab",
"minecraft:petrified_oak_slab",
"minecraft:prismarine_slab",
"minecraft:prismarine_brick_slab",
"minecraft:dark_prismarine_slab",
"minecraft:polished_granite_slab",
"minecraft:smooth_red_sandstone_slab",
"minecraft:mossy_stone_brick_slab",
"minecraft:polished_diorite_slab",
"minecraft:mossy_cobblestone_slab",
"minecraft:end_stone_brick_slab",
"minecraft:smooth_sandstone_slab",
"minecraft:smooth_quartz_slab",
"minecraft:granite_slab",
"minecraft:andesite_slab",
"minecraft:red_nether_brick_slab",
"minecraft:polished_andesite_slab",
"minecraft:diorite_slab",
"minecraft:cut_sandstone_slab",
"minecraft:cut_red_sandstone_slab"
],
"minecraft:small_flowers": [
"minecraft:dandelion",
"minecraft:poppy",
"minecraft:blue_orchid",
"minecraft:allium",
"minecraft:azure_bluet",
"minecraft:red_tulip",
"minecraft:orange_tulip",
"minecraft:white_tulip",
"minecraft:pink_tulip",
"minecraft:oxeye_daisy",
"minecraft:cornflower",
"minecraft:lily_of_the_valley",
"minecraft:wither_rose"
],
"minecraft:spruce_logs": [
"minecraft:spruce_log",
"minecraft:spruce_wood",
"minecraft:stripped_spruce_log",
"minecraft:stripped_spruce_wood"
],
"minecraft:stairs": [
"minecraft:oak_stairs",
"minecraft:cobblestone_stairs",
"minecraft:spruce_stairs",
"minecraft:sandstone_stairs",
"minecraft:acacia_stairs",
"minecraft:jungle_stairs",
"minecraft:birch_stairs",
"minecraft:dark_oak_stairs",
"minecraft:nether_brick_stairs",
"minecraft:stone_brick_stairs",
"minecraft:brick_stairs",
"minecraft:purpur_stairs",
"minecraft:quartz_stairs",
"minecraft:red_sandstone_stairs",
"minecraft:prismarine_brick_stairs",
"minecraft:prismarine_stairs",
"minecraft:dark_prismarine_stairs",
"minecraft:polished_granite_stairs",
"minecraft:smooth_red_sandstone_stairs",
"minecraft:mossy_stone_brick_stairs",
"minecraft:polished_diorite_stairs",
"minecraft:mossy_cobblestone_stairs",
"minecraft:end_stone_brick_stairs",
"minecraft:stone_stairs",
"minecraft:smooth_sandstone_stairs",
"minecraft:smooth_quartz_stairs",
"minecraft:granite_stairs",
"minecraft:andesite_stairs",
"minecraft:red_nether_brick_stairs",
"minecraft:polished_andesite_stairs",
"minecraft:diorite_stairs"
],
"minecraft:stone_bricks": [
"minecraft:stone_bricks",
"minecraft:mossy_stone_bricks",
"minecraft:cracked_stone_bricks",
"minecraft:chiseled_stone_bricks"
],
"minecraft:tall_flowers": [
"minecraft:sunflower",
"minecraft:lilac",
"minecraft:peony",
"minecraft:rose_bush"
],
"minecraft:trapdoors": [
"minecraft:acacia_trapdoor",
"minecraft:birch_trapdoor",
"minecraft:dark_oak_trapdoor",
"minecraft:jungle_trapdoor",
"minecraft:oak_trapdoor",
"minecraft:spruce_trapdoor",
"minecraft:iron_trapdoor"
],
"minecraft:walls": [
"minecraft:cobblestone_wall",
"minecraft:mossy_cobblestone_wall",
"minecraft:brick_wall",
"minecraft:prismarine_wall",
"minecraft:red_sandstone_wall",
"minecraft:mossy_stone_brick_wall",
"minecraft:granite_wall",
"minecraft:stone_brick_wall",
"minecraft:nether_brick_wall",
"minecraft:andesite_wall",
"minecraft:red_nether_brick_wall",
"minecraft:sandstone_wall",
"minecraft:end_stone_brick_wall",
"minecraft:diorite_wall"
],
"minecraft:wooden_buttons": [
"minecraft:oak_button",
"minecraft:spruce_button",
"minecraft:birch_button",
"minecraft:jungle_button",
"minecraft:acacia_button",
"minecraft:dark_oak_button"
],
"minecraft:wooden_doors": [
"minecraft:oak_door",
"minecraft:spruce_door",
"minecraft:birch_door",
"minecraft:jungle_door",
"minecraft:acacia_door",
"minecraft:dark_oak_door"
],
"minecraft:wooden_fences": [
"minecraft:oak_fence",
"minecraft:acacia_fence",
"minecraft:dark_oak_fence",
"minecraft:spruce_fence",
"minecraft:birch_fence",
"minecraft:jungle_fence"
],
"minecraft:wooden_pressure_plates": [
"minecraft:oak_pressure_plate",
"minecraft:spruce_pressure_plate",
"minecraft:birch_pressure_plate",
"minecraft:jungle_pressure_plate",
"minecraft:acacia_pressure_plate",
"minecraft:dark_oak_pressure_plate"
],
"minecraft:wooden_slabs": [
"minecraft:oak_slab",
"minecraft:spruce_slab",
"minecraft:birch_slab",
"minecraft:jungle_slab",
"minecraft:acacia_slab",
"minecraft:dark_oak_slab"
],
"minecraft:wooden_stairs": [
"minecraft:oak_stairs",
"minecraft:spruce_stairs",
"minecraft:birch_stairs",
"minecraft:jungle_stairs",
"minecraft:acacia_stairs",
"minecraft:dark_oak_stairs"
],
"minecraft:wooden_trapdoors": [
"minecraft:acacia_trapdoor",
"minecraft:birch_trapdoor",
"minecraft:dark_oak_trapdoor",
"minecraft:jungle_trapdoor",
"minecraft:oak_trapdoor",
"minecraft:spruce_trapdoor"
],
"minecraft:wool": [
"minecraft:white_wool",
"minecraft:orange_wool",
"minecraft:magenta_wool",
"minecraft:light_blue_wool",
"minecraft:yellow_wool",
"minecraft:lime_wool",
"minecraft:pink_wool",
"minecraft:gray_wool",
"minecraft:light_gray_wool",
"minecraft:cyan_wool",
"minecraft:purple_wool",
"minecraft:blue_wool",
"minecraft:brown_wool",
"minecraft:green_wool",
"minecraft:red_wool",
"minecraft:black_wool"
]
}

View File

@ -0,0 +1,604 @@
{
"minecraft:acacia_logs": [
"minecraft:acacia_log",
"minecraft:acacia_wood",
"minecraft:stripped_acacia_log",
"minecraft:stripped_acacia_wood"
],
"minecraft:anvil": ["minecraft:anvil", "minecraft:chipped_anvil", "minecraft:damaged_anvil"],
"minecraft:arrows": ["minecraft:arrow", "minecraft:tipped_arrow", "minecraft:spectral_arrow"],
"minecraft:banners": [
"minecraft:white_banner",
"minecraft:orange_banner",
"minecraft:magenta_banner",
"minecraft:light_blue_banner",
"minecraft:yellow_banner",
"minecraft:lime_banner",
"minecraft:pink_banner",
"minecraft:gray_banner",
"minecraft:light_gray_banner",
"minecraft:cyan_banner",
"minecraft:purple_banner",
"minecraft:blue_banner",
"minecraft:brown_banner",
"minecraft:green_banner",
"minecraft:red_banner",
"minecraft:black_banner"
],
"minecraft:beacon_payment_items": [
"minecraft:netherite_ingot",
"minecraft:emerald",
"minecraft:diamond",
"minecraft:gold_ingot",
"minecraft:iron_ingot"
],
"minecraft:beds": [
"minecraft:red_bed",
"minecraft:black_bed",
"minecraft:blue_bed",
"minecraft:brown_bed",
"minecraft:cyan_bed",
"minecraft:gray_bed",
"minecraft:green_bed",
"minecraft:light_blue_bed",
"minecraft:light_gray_bed",
"minecraft:lime_bed",
"minecraft:magenta_bed",
"minecraft:orange_bed",
"minecraft:pink_bed",
"minecraft:purple_bed",
"minecraft:white_bed",
"minecraft:yellow_bed"
],
"minecraft:birch_logs": [
"minecraft:birch_log",
"minecraft:birch_wood",
"minecraft:stripped_birch_log",
"minecraft:stripped_birch_wood"
],
"minecraft:boats": [
"minecraft:oak_boat",
"minecraft:spruce_boat",
"minecraft:birch_boat",
"minecraft:jungle_boat",
"minecraft:acacia_boat",
"minecraft:dark_oak_boat"
],
"minecraft:buttons": [
"minecraft:oak_button",
"minecraft:spruce_button",
"minecraft:birch_button",
"minecraft:jungle_button",
"minecraft:acacia_button",
"minecraft:dark_oak_button",
"minecraft:crimson_button",
"minecraft:warped_button",
"minecraft:stone_button",
"minecraft:polished_blackstone_button"
],
"minecraft:carpets": [
"minecraft:white_carpet",
"minecraft:orange_carpet",
"minecraft:magenta_carpet",
"minecraft:light_blue_carpet",
"minecraft:yellow_carpet",
"minecraft:lime_carpet",
"minecraft:pink_carpet",
"minecraft:gray_carpet",
"minecraft:light_gray_carpet",
"minecraft:cyan_carpet",
"minecraft:purple_carpet",
"minecraft:blue_carpet",
"minecraft:brown_carpet",
"minecraft:green_carpet",
"minecraft:red_carpet",
"minecraft:black_carpet"
],
"minecraft:coals": ["minecraft:coal", "minecraft:charcoal"],
"minecraft:creeper_drop_music_discs": [
"minecraft:music_disc_13",
"minecraft:music_disc_cat",
"minecraft:music_disc_blocks",
"minecraft:music_disc_chirp",
"minecraft:music_disc_far",
"minecraft:music_disc_mall",
"minecraft:music_disc_mellohi",
"minecraft:music_disc_stal",
"minecraft:music_disc_strad",
"minecraft:music_disc_ward",
"minecraft:music_disc_11",
"minecraft:music_disc_wait"
],
"minecraft:crimson_stems": [
"minecraft:crimson_stem",
"minecraft:stripped_crimson_stem",
"minecraft:crimson_hyphae",
"minecraft:stripped_crimson_hyphae"
],
"minecraft:dark_oak_logs": [
"minecraft:dark_oak_log",
"minecraft:dark_oak_wood",
"minecraft:stripped_dark_oak_log",
"minecraft:stripped_dark_oak_wood"
],
"minecraft:doors": [
"minecraft:oak_door",
"minecraft:spruce_door",
"minecraft:birch_door",
"minecraft:jungle_door",
"minecraft:acacia_door",
"minecraft:dark_oak_door",
"minecraft:crimson_door",
"minecraft:warped_door",
"minecraft:iron_door"
],
"minecraft:fences": [
"minecraft:oak_fence",
"minecraft:acacia_fence",
"minecraft:dark_oak_fence",
"minecraft:spruce_fence",
"minecraft:birch_fence",
"minecraft:jungle_fence",
"minecraft:crimson_fence",
"minecraft:warped_fence",
"minecraft:nether_brick_fence"
],
"minecraft:fishes": [
"minecraft:cod",
"minecraft:cooked_cod",
"minecraft:salmon",
"minecraft:cooked_salmon",
"minecraft:pufferfish",
"minecraft:tropical_fish"
],
"minecraft:flowers": [
"minecraft:dandelion",
"minecraft:poppy",
"minecraft:blue_orchid",
"minecraft:allium",
"minecraft:azure_bluet",
"minecraft:red_tulip",
"minecraft:orange_tulip",
"minecraft:white_tulip",
"minecraft:pink_tulip",
"minecraft:oxeye_daisy",
"minecraft:cornflower",
"minecraft:lily_of_the_valley",
"minecraft:wither_rose",
"minecraft:sunflower",
"minecraft:lilac",
"minecraft:peony",
"minecraft:rose_bush"
],
"minecraft:furnace_materials": ["minecraft:cobblestone", "minecraft:blackstone"],
"minecraft:gold_ores": ["minecraft:gold_ore", "minecraft:nether_gold_ore"],
"minecraft:jungle_logs": [
"minecraft:jungle_log",
"minecraft:jungle_wood",
"minecraft:stripped_jungle_log",
"minecraft:stripped_jungle_wood"
],
"minecraft:leaves": [
"minecraft:jungle_leaves",
"minecraft:oak_leaves",
"minecraft:spruce_leaves",
"minecraft:dark_oak_leaves",
"minecraft:acacia_leaves",
"minecraft:birch_leaves"
],
"minecraft:lectern_books": ["minecraft:written_book", "minecraft:writable_book"],
"minecraft:logs": [
"minecraft:dark_oak_log",
"minecraft:dark_oak_wood",
"minecraft:stripped_dark_oak_log",
"minecraft:stripped_dark_oak_wood",
"minecraft:oak_log",
"minecraft:oak_wood",
"minecraft:stripped_oak_log",
"minecraft:stripped_oak_wood",
"minecraft:acacia_log",
"minecraft:acacia_wood",
"minecraft:stripped_acacia_log",
"minecraft:stripped_acacia_wood",
"minecraft:birch_log",
"minecraft:birch_wood",
"minecraft:stripped_birch_log",
"minecraft:stripped_birch_wood",
"minecraft:jungle_log",
"minecraft:jungle_wood",
"minecraft:stripped_jungle_log",
"minecraft:stripped_jungle_wood",
"minecraft:spruce_log",
"minecraft:spruce_wood",
"minecraft:stripped_spruce_log",
"minecraft:stripped_spruce_wood",
"minecraft:crimson_stem",
"minecraft:stripped_crimson_stem",
"minecraft:crimson_hyphae",
"minecraft:stripped_crimson_hyphae",
"minecraft:warped_stem",
"minecraft:stripped_warped_stem",
"minecraft:warped_hyphae",
"minecraft:stripped_warped_hyphae"
],
"minecraft:logs_that_burn": [
"minecraft:dark_oak_log",
"minecraft:dark_oak_wood",
"minecraft:stripped_dark_oak_log",
"minecraft:stripped_dark_oak_wood",
"minecraft:oak_log",
"minecraft:oak_wood",
"minecraft:stripped_oak_log",
"minecraft:stripped_oak_wood",
"minecraft:acacia_log",
"minecraft:acacia_wood",
"minecraft:stripped_acacia_log",
"minecraft:stripped_acacia_wood",
"minecraft:birch_log",
"minecraft:birch_wood",
"minecraft:stripped_birch_log",
"minecraft:stripped_birch_wood",
"minecraft:jungle_log",
"minecraft:jungle_wood",
"minecraft:stripped_jungle_log",
"minecraft:stripped_jungle_wood",
"minecraft:spruce_log",
"minecraft:spruce_wood",
"minecraft:stripped_spruce_log",
"minecraft:stripped_spruce_wood"
],
"minecraft:music_discs": [
"minecraft:music_disc_13",
"minecraft:music_disc_cat",
"minecraft:music_disc_blocks",
"minecraft:music_disc_chirp",
"minecraft:music_disc_far",
"minecraft:music_disc_mall",
"minecraft:music_disc_mellohi",
"minecraft:music_disc_stal",
"minecraft:music_disc_strad",
"minecraft:music_disc_ward",
"minecraft:music_disc_11",
"minecraft:music_disc_wait",
"minecraft:music_disc_pigstep"
],
"minecraft:non_flammable_wood": [
"minecraft:warped_stem",
"minecraft:stripped_warped_stem",
"minecraft:warped_hyphae",
"minecraft:stripped_warped_hyphae",
"minecraft:crimson_stem",
"minecraft:stripped_crimson_stem",
"minecraft:crimson_hyphae",
"minecraft:stripped_crimson_hyphae",
"minecraft:crimson_planks",
"minecraft:warped_planks",
"minecraft:crimson_slab",
"minecraft:warped_slab",
"minecraft:crimson_pressure_plate",
"minecraft:warped_pressure_plate",
"minecraft:crimson_fence",
"minecraft:warped_fence",
"minecraft:crimson_trapdoor",
"minecraft:warped_trapdoor",
"minecraft:crimson_fence_gate",
"minecraft:warped_fence_gate",
"minecraft:crimson_stairs",
"minecraft:warped_stairs",
"minecraft:crimson_button",
"minecraft:warped_button",
"minecraft:crimson_door",
"minecraft:warped_door",
"minecraft:crimson_sign",
"minecraft:warped_sign"
],
"minecraft:oak_logs": [
"minecraft:oak_log",
"minecraft:oak_wood",
"minecraft:stripped_oak_log",
"minecraft:stripped_oak_wood"
],
"minecraft:piglin_loved": [
"minecraft:gold_ore",
"minecraft:nether_gold_ore",
"minecraft:gold_block",
"minecraft:gilded_blackstone",
"minecraft:light_weighted_pressure_plate",
"minecraft:gold_ingot",
"minecraft:bell",
"minecraft:clock",
"minecraft:golden_carrot",
"minecraft:glistering_melon_slice",
"minecraft:golden_apple",
"minecraft:enchanted_golden_apple",
"minecraft:golden_helmet",
"minecraft:golden_chestplate",
"minecraft:golden_leggings",
"minecraft:golden_boots",
"minecraft:golden_horse_armor",
"minecraft:golden_sword",
"minecraft:golden_pickaxe",
"minecraft:golden_shovel",
"minecraft:golden_axe",
"minecraft:golden_hoe"
],
"minecraft:piglin_repellents": [
"minecraft:soul_torch",
"minecraft:soul_lantern",
"minecraft:soul_campfire"
],
"minecraft:planks": [
"minecraft:oak_planks",
"minecraft:spruce_planks",
"minecraft:birch_planks",
"minecraft:jungle_planks",
"minecraft:acacia_planks",
"minecraft:dark_oak_planks",
"minecraft:crimson_planks",
"minecraft:warped_planks"
],
"minecraft:rails": [
"minecraft:rail",
"minecraft:powered_rail",
"minecraft:detector_rail",
"minecraft:activator_rail"
],
"minecraft:sand": ["minecraft:sand", "minecraft:red_sand"],
"minecraft:saplings": [
"minecraft:oak_sapling",
"minecraft:spruce_sapling",
"minecraft:birch_sapling",
"minecraft:jungle_sapling",
"minecraft:acacia_sapling",
"minecraft:dark_oak_sapling"
],
"minecraft:signs": [
"minecraft:oak_sign",
"minecraft:spruce_sign",
"minecraft:birch_sign",
"minecraft:acacia_sign",
"minecraft:jungle_sign",
"minecraft:dark_oak_sign",
"minecraft:crimson_sign",
"minecraft:warped_sign"
],
"minecraft:slabs": [
"minecraft:oak_slab",
"minecraft:spruce_slab",
"minecraft:birch_slab",
"minecraft:jungle_slab",
"minecraft:acacia_slab",
"minecraft:dark_oak_slab",
"minecraft:crimson_slab",
"minecraft:warped_slab",
"minecraft:stone_slab",
"minecraft:smooth_stone_slab",
"minecraft:stone_brick_slab",
"minecraft:sandstone_slab",
"minecraft:purpur_slab",
"minecraft:quartz_slab",
"minecraft:red_sandstone_slab",
"minecraft:brick_slab",
"minecraft:cobblestone_slab",
"minecraft:nether_brick_slab",
"minecraft:petrified_oak_slab",
"minecraft:prismarine_slab",
"minecraft:prismarine_brick_slab",
"minecraft:dark_prismarine_slab",
"minecraft:polished_granite_slab",
"minecraft:smooth_red_sandstone_slab",
"minecraft:mossy_stone_brick_slab",
"minecraft:polished_diorite_slab",
"minecraft:mossy_cobblestone_slab",
"minecraft:end_stone_brick_slab",
"minecraft:smooth_sandstone_slab",
"minecraft:smooth_quartz_slab",
"minecraft:granite_slab",
"minecraft:andesite_slab",
"minecraft:red_nether_brick_slab",
"minecraft:polished_andesite_slab",
"minecraft:diorite_slab",
"minecraft:cut_sandstone_slab",
"minecraft:cut_red_sandstone_slab",
"minecraft:blackstone_slab",
"minecraft:polished_blackstone_brick_slab",
"minecraft:polished_blackstone_slab"
],
"minecraft:small_flowers": [
"minecraft:dandelion",
"minecraft:poppy",
"minecraft:blue_orchid",
"minecraft:allium",
"minecraft:azure_bluet",
"minecraft:red_tulip",
"minecraft:orange_tulip",
"minecraft:white_tulip",
"minecraft:pink_tulip",
"minecraft:oxeye_daisy",
"minecraft:cornflower",
"minecraft:lily_of_the_valley",
"minecraft:wither_rose"
],
"minecraft:soul_fire_base_blocks": ["minecraft:soul_sand", "minecraft:soul_soil"],
"minecraft:spruce_logs": [
"minecraft:spruce_log",
"minecraft:spruce_wood",
"minecraft:stripped_spruce_log",
"minecraft:stripped_spruce_wood"
],
"minecraft:stairs": [
"minecraft:oak_stairs",
"minecraft:spruce_stairs",
"minecraft:birch_stairs",
"minecraft:jungle_stairs",
"minecraft:acacia_stairs",
"minecraft:dark_oak_stairs",
"minecraft:crimson_stairs",
"minecraft:warped_stairs",
"minecraft:cobblestone_stairs",
"minecraft:sandstone_stairs",
"minecraft:nether_brick_stairs",
"minecraft:stone_brick_stairs",
"minecraft:brick_stairs",
"minecraft:purpur_stairs",
"minecraft:quartz_stairs",
"minecraft:red_sandstone_stairs",
"minecraft:prismarine_brick_stairs",
"minecraft:prismarine_stairs",
"minecraft:dark_prismarine_stairs",
"minecraft:polished_granite_stairs",
"minecraft:smooth_red_sandstone_stairs",
"minecraft:mossy_stone_brick_stairs",
"minecraft:polished_diorite_stairs",
"minecraft:mossy_cobblestone_stairs",
"minecraft:end_stone_brick_stairs",
"minecraft:stone_stairs",
"minecraft:smooth_sandstone_stairs",
"minecraft:smooth_quartz_stairs",
"minecraft:granite_stairs",
"minecraft:andesite_stairs",
"minecraft:red_nether_brick_stairs",
"minecraft:polished_andesite_stairs",
"minecraft:diorite_stairs",
"minecraft:blackstone_stairs",
"minecraft:polished_blackstone_brick_stairs",
"minecraft:polished_blackstone_stairs"
],
"minecraft:stone_bricks": [
"minecraft:stone_bricks",
"minecraft:mossy_stone_bricks",
"minecraft:cracked_stone_bricks",
"minecraft:chiseled_stone_bricks"
],
"minecraft:stone_tool_materials": ["minecraft:cobblestone", "minecraft:blackstone"],
"minecraft:tall_flowers": [
"minecraft:sunflower",
"minecraft:lilac",
"minecraft:peony",
"minecraft:rose_bush"
],
"minecraft:trapdoors": [
"minecraft:acacia_trapdoor",
"minecraft:birch_trapdoor",
"minecraft:dark_oak_trapdoor",
"minecraft:jungle_trapdoor",
"minecraft:oak_trapdoor",
"minecraft:spruce_trapdoor",
"minecraft:crimson_trapdoor",
"minecraft:warped_trapdoor",
"minecraft:iron_trapdoor"
],
"minecraft:walls": [
"minecraft:cobblestone_wall",
"minecraft:mossy_cobblestone_wall",
"minecraft:brick_wall",
"minecraft:prismarine_wall",
"minecraft:red_sandstone_wall",
"minecraft:mossy_stone_brick_wall",
"minecraft:granite_wall",
"minecraft:stone_brick_wall",
"minecraft:nether_brick_wall",
"minecraft:andesite_wall",
"minecraft:red_nether_brick_wall",
"minecraft:sandstone_wall",
"minecraft:end_stone_brick_wall",
"minecraft:diorite_wall",
"minecraft:blackstone_wall",
"minecraft:polished_blackstone_brick_wall",
"minecraft:polished_blackstone_wall"
],
"minecraft:warped_stems": [
"minecraft:warped_stem",
"minecraft:stripped_warped_stem",
"minecraft:warped_hyphae",
"minecraft:stripped_warped_hyphae"
],
"minecraft:wooden_buttons": [
"minecraft:oak_button",
"minecraft:spruce_button",
"minecraft:birch_button",
"minecraft:jungle_button",
"minecraft:acacia_button",
"minecraft:dark_oak_button",
"minecraft:crimson_button",
"minecraft:warped_button"
],
"minecraft:wooden_doors": [
"minecraft:oak_door",
"minecraft:spruce_door",
"minecraft:birch_door",
"minecraft:jungle_door",
"minecraft:acacia_door",
"minecraft:dark_oak_door",
"minecraft:crimson_door",
"minecraft:warped_door"
],
"minecraft:wooden_fences": [
"minecraft:oak_fence",
"minecraft:acacia_fence",
"minecraft:dark_oak_fence",
"minecraft:spruce_fence",
"minecraft:birch_fence",
"minecraft:jungle_fence",
"minecraft:crimson_fence",
"minecraft:warped_fence"
],
"minecraft:wooden_pressure_plates": [
"minecraft:oak_pressure_plate",
"minecraft:spruce_pressure_plate",
"minecraft:birch_pressure_plate",
"minecraft:jungle_pressure_plate",
"minecraft:acacia_pressure_plate",
"minecraft:dark_oak_pressure_plate",
"minecraft:crimson_pressure_plate",
"minecraft:warped_pressure_plate"
],
"minecraft:wooden_slabs": [
"minecraft:oak_slab",
"minecraft:spruce_slab",
"minecraft:birch_slab",
"minecraft:jungle_slab",
"minecraft:acacia_slab",
"minecraft:dark_oak_slab",
"minecraft:crimson_slab",
"minecraft:warped_slab"
],
"minecraft:wooden_stairs": [
"minecraft:oak_stairs",
"minecraft:spruce_stairs",
"minecraft:birch_stairs",
"minecraft:jungle_stairs",
"minecraft:acacia_stairs",
"minecraft:dark_oak_stairs",
"minecraft:crimson_stairs",
"minecraft:warped_stairs"
],
"minecraft:wooden_trapdoors": [
"minecraft:acacia_trapdoor",
"minecraft:birch_trapdoor",
"minecraft:dark_oak_trapdoor",
"minecraft:jungle_trapdoor",
"minecraft:oak_trapdoor",
"minecraft:spruce_trapdoor",
"minecraft:crimson_trapdoor",
"minecraft:warped_trapdoor"
],
"minecraft:wool": [
"minecraft:white_wool",
"minecraft:orange_wool",
"minecraft:magenta_wool",
"minecraft:light_blue_wool",
"minecraft:yellow_wool",
"minecraft:lime_wool",
"minecraft:pink_wool",
"minecraft:gray_wool",
"minecraft:light_gray_wool",
"minecraft:cyan_wool",
"minecraft:purple_wool",
"minecraft:blue_wool",
"minecraft:brown_wool",
"minecraft:green_wool",
"minecraft:red_wool",
"minecraft:black_wool"
]
}

View File

@ -0,0 +1,716 @@
{
"minecraft:acacia_logs": [
"minecraft:acacia_log",
"minecraft:acacia_wood",
"minecraft:stripped_acacia_log",
"minecraft:stripped_acacia_wood"
],
"minecraft:anvil": ["minecraft:anvil", "minecraft:chipped_anvil", "minecraft:damaged_anvil"],
"minecraft:arrows": ["minecraft:arrow", "minecraft:tipped_arrow", "minecraft:spectral_arrow"],
"minecraft:axolotl_tempt_items": ["minecraft:tropical_fish_bucket"],
"minecraft:banners": [
"minecraft:white_banner",
"minecraft:orange_banner",
"minecraft:magenta_banner",
"minecraft:light_blue_banner",
"minecraft:yellow_banner",
"minecraft:lime_banner",
"minecraft:pink_banner",
"minecraft:gray_banner",
"minecraft:light_gray_banner",
"minecraft:cyan_banner",
"minecraft:purple_banner",
"minecraft:blue_banner",
"minecraft:brown_banner",
"minecraft:green_banner",
"minecraft:red_banner",
"minecraft:black_banner"
],
"minecraft:beacon_payment_items": [
"minecraft:netherite_ingot",
"minecraft:emerald",
"minecraft:diamond",
"minecraft:gold_ingot",
"minecraft:iron_ingot"
],
"minecraft:beds": [
"minecraft:red_bed",
"minecraft:black_bed",
"minecraft:blue_bed",
"minecraft:brown_bed",
"minecraft:cyan_bed",
"minecraft:gray_bed",
"minecraft:green_bed",
"minecraft:light_blue_bed",
"minecraft:light_gray_bed",
"minecraft:lime_bed",
"minecraft:magenta_bed",
"minecraft:orange_bed",
"minecraft:pink_bed",
"minecraft:purple_bed",
"minecraft:white_bed",
"minecraft:yellow_bed"
],
"minecraft:birch_logs": [
"minecraft:birch_log",
"minecraft:birch_wood",
"minecraft:stripped_birch_log",
"minecraft:stripped_birch_wood"
],
"minecraft:boats": [
"minecraft:oak_boat",
"minecraft:spruce_boat",
"minecraft:birch_boat",
"minecraft:jungle_boat",
"minecraft:acacia_boat",
"minecraft:dark_oak_boat"
],
"minecraft:buttons": [
"minecraft:oak_button",
"minecraft:spruce_button",
"minecraft:birch_button",
"minecraft:jungle_button",
"minecraft:acacia_button",
"minecraft:dark_oak_button",
"minecraft:crimson_button",
"minecraft:warped_button",
"minecraft:stone_button",
"minecraft:polished_blackstone_button"
],
"minecraft:candles": [
"minecraft:candle",
"minecraft:white_candle",
"minecraft:orange_candle",
"minecraft:magenta_candle",
"minecraft:light_blue_candle",
"minecraft:yellow_candle",
"minecraft:lime_candle",
"minecraft:pink_candle",
"minecraft:gray_candle",
"minecraft:light_gray_candle",
"minecraft:cyan_candle",
"minecraft:purple_candle",
"minecraft:blue_candle",
"minecraft:brown_candle",
"minecraft:green_candle",
"minecraft:red_candle",
"minecraft:black_candle"
],
"minecraft:carpets": [
"minecraft:white_carpet",
"minecraft:orange_carpet",
"minecraft:magenta_carpet",
"minecraft:light_blue_carpet",
"minecraft:yellow_carpet",
"minecraft:lime_carpet",
"minecraft:pink_carpet",
"minecraft:gray_carpet",
"minecraft:light_gray_carpet",
"minecraft:cyan_carpet",
"minecraft:purple_carpet",
"minecraft:blue_carpet",
"minecraft:brown_carpet",
"minecraft:green_carpet",
"minecraft:red_carpet",
"minecraft:black_carpet"
],
"minecraft:cluster_max_harvestables": [
"minecraft:diamond_pickaxe",
"minecraft:golden_pickaxe",
"minecraft:iron_pickaxe",
"minecraft:netherite_pickaxe",
"minecraft:stone_pickaxe",
"minecraft:wooden_pickaxe"
],
"minecraft:coal_ores": ["minecraft:coal_ore", "minecraft:deepslate_coal_ore"],
"minecraft:coals": ["minecraft:coal", "minecraft:charcoal"],
"minecraft:copper_ores": ["minecraft:copper_ore", "minecraft:deepslate_copper_ore"],
"minecraft:creeper_drop_music_discs": [
"minecraft:music_disc_13",
"minecraft:music_disc_cat",
"minecraft:music_disc_blocks",
"minecraft:music_disc_chirp",
"minecraft:music_disc_far",
"minecraft:music_disc_mall",
"minecraft:music_disc_mellohi",
"minecraft:music_disc_stal",
"minecraft:music_disc_strad",
"minecraft:music_disc_ward",
"minecraft:music_disc_11",
"minecraft:music_disc_wait"
],
"minecraft:crimson_stems": [
"minecraft:crimson_stem",
"minecraft:stripped_crimson_stem",
"minecraft:crimson_hyphae",
"minecraft:stripped_crimson_hyphae"
],
"minecraft:dark_oak_logs": [
"minecraft:dark_oak_log",
"minecraft:dark_oak_wood",
"minecraft:stripped_dark_oak_log",
"minecraft:stripped_dark_oak_wood"
],
"minecraft:diamond_ores": ["minecraft:diamond_ore", "minecraft:deepslate_diamond_ore"],
"minecraft:doors": [
"minecraft:oak_door",
"minecraft:spruce_door",
"minecraft:birch_door",
"minecraft:jungle_door",
"minecraft:acacia_door",
"minecraft:dark_oak_door",
"minecraft:crimson_door",
"minecraft:warped_door",
"minecraft:iron_door"
],
"minecraft:emerald_ores": ["minecraft:emerald_ore", "minecraft:deepslate_emerald_ore"],
"minecraft:fences": [
"minecraft:oak_fence",
"minecraft:acacia_fence",
"minecraft:dark_oak_fence",
"minecraft:spruce_fence",
"minecraft:birch_fence",
"minecraft:jungle_fence",
"minecraft:crimson_fence",
"minecraft:warped_fence",
"minecraft:nether_brick_fence"
],
"minecraft:fishes": [
"minecraft:cod",
"minecraft:cooked_cod",
"minecraft:salmon",
"minecraft:cooked_salmon",
"minecraft:pufferfish",
"minecraft:tropical_fish"
],
"minecraft:flowers": [
"minecraft:dandelion",
"minecraft:poppy",
"minecraft:blue_orchid",
"minecraft:allium",
"minecraft:azure_bluet",
"minecraft:red_tulip",
"minecraft:orange_tulip",
"minecraft:white_tulip",
"minecraft:pink_tulip",
"minecraft:oxeye_daisy",
"minecraft:cornflower",
"minecraft:lily_of_the_valley",
"minecraft:wither_rose",
"minecraft:sunflower",
"minecraft:lilac",
"minecraft:peony",
"minecraft:rose_bush",
"minecraft:flowering_azalea_leaves",
"minecraft:flowering_azalea"
],
"minecraft:fox_food": ["minecraft:sweet_berries", "minecraft:glow_berries"],
"minecraft:freeze_immune_wearables": [
"minecraft:leather_boots",
"minecraft:leather_leggings",
"minecraft:leather_chestplate",
"minecraft:leather_helmet",
"minecraft:leather_horse_armor"
],
"minecraft:gold_ores": [
"minecraft:gold_ore",
"minecraft:nether_gold_ore",
"minecraft:deepslate_gold_ore"
],
"minecraft:ignored_by_piglin_babies": ["minecraft:leather"],
"minecraft:iron_ores": ["minecraft:iron_ore", "minecraft:deepslate_iron_ore"],
"minecraft:jungle_logs": [
"minecraft:jungle_log",
"minecraft:jungle_wood",
"minecraft:stripped_jungle_log",
"minecraft:stripped_jungle_wood"
],
"minecraft:lapis_ores": ["minecraft:lapis_ore", "minecraft:deepslate_lapis_ore"],
"minecraft:leaves": [
"minecraft:jungle_leaves",
"minecraft:oak_leaves",
"minecraft:spruce_leaves",
"minecraft:dark_oak_leaves",
"minecraft:acacia_leaves",
"minecraft:birch_leaves",
"minecraft:azalea_leaves",
"minecraft:flowering_azalea_leaves"
],
"minecraft:lectern_books": ["minecraft:written_book", "minecraft:writable_book"],
"minecraft:logs": [
"minecraft:dark_oak_log",
"minecraft:dark_oak_wood",
"minecraft:stripped_dark_oak_log",
"minecraft:stripped_dark_oak_wood",
"minecraft:oak_log",
"minecraft:oak_wood",
"minecraft:stripped_oak_log",
"minecraft:stripped_oak_wood",
"minecraft:acacia_log",
"minecraft:acacia_wood",
"minecraft:stripped_acacia_log",
"minecraft:stripped_acacia_wood",
"minecraft:birch_log",
"minecraft:birch_wood",
"minecraft:stripped_birch_log",
"minecraft:stripped_birch_wood",
"minecraft:jungle_log",
"minecraft:jungle_wood",
"minecraft:stripped_jungle_log",
"minecraft:stripped_jungle_wood",
"minecraft:spruce_log",
"minecraft:spruce_wood",
"minecraft:stripped_spruce_log",
"minecraft:stripped_spruce_wood",
"minecraft:crimson_stem",
"minecraft:stripped_crimson_stem",
"minecraft:crimson_hyphae",
"minecraft:stripped_crimson_hyphae",
"minecraft:warped_stem",
"minecraft:stripped_warped_stem",
"minecraft:warped_hyphae",
"minecraft:stripped_warped_hyphae"
],
"minecraft:logs_that_burn": [
"minecraft:dark_oak_log",
"minecraft:dark_oak_wood",
"minecraft:stripped_dark_oak_log",
"minecraft:stripped_dark_oak_wood",
"minecraft:oak_log",
"minecraft:oak_wood",
"minecraft:stripped_oak_log",
"minecraft:stripped_oak_wood",
"minecraft:acacia_log",
"minecraft:acacia_wood",
"minecraft:stripped_acacia_log",
"minecraft:stripped_acacia_wood",
"minecraft:birch_log",
"minecraft:birch_wood",
"minecraft:stripped_birch_log",
"minecraft:stripped_birch_wood",
"minecraft:jungle_log",
"minecraft:jungle_wood",
"minecraft:stripped_jungle_log",
"minecraft:stripped_jungle_wood",
"minecraft:spruce_log",
"minecraft:spruce_wood",
"minecraft:stripped_spruce_log",
"minecraft:stripped_spruce_wood"
],
"minecraft:music_discs": [
"minecraft:music_disc_13",
"minecraft:music_disc_cat",
"minecraft:music_disc_blocks",
"minecraft:music_disc_chirp",
"minecraft:music_disc_far",
"minecraft:music_disc_mall",
"minecraft:music_disc_mellohi",
"minecraft:music_disc_stal",
"minecraft:music_disc_strad",
"minecraft:music_disc_ward",
"minecraft:music_disc_11",
"minecraft:music_disc_wait",
"minecraft:music_disc_pigstep"
],
"minecraft:non_flammable_wood": [
"minecraft:warped_stem",
"minecraft:stripped_warped_stem",
"minecraft:warped_hyphae",
"minecraft:stripped_warped_hyphae",
"minecraft:crimson_stem",
"minecraft:stripped_crimson_stem",
"minecraft:crimson_hyphae",
"minecraft:stripped_crimson_hyphae",
"minecraft:crimson_planks",
"minecraft:warped_planks",
"minecraft:crimson_slab",
"minecraft:warped_slab",
"minecraft:crimson_pressure_plate",
"minecraft:warped_pressure_plate",
"minecraft:crimson_fence",
"minecraft:warped_fence",
"minecraft:crimson_trapdoor",
"minecraft:warped_trapdoor",
"minecraft:crimson_fence_gate",
"minecraft:warped_fence_gate",
"minecraft:crimson_stairs",
"minecraft:warped_stairs",
"minecraft:crimson_button",
"minecraft:warped_button",
"minecraft:crimson_door",
"minecraft:warped_door",
"minecraft:crimson_sign",
"minecraft:warped_sign"
],
"minecraft:oak_logs": [
"minecraft:oak_log",
"minecraft:oak_wood",
"minecraft:stripped_oak_log",
"minecraft:stripped_oak_wood"
],
"minecraft:occludes_vibration_signals": [
"minecraft:white_wool",
"minecraft:orange_wool",
"minecraft:magenta_wool",
"minecraft:light_blue_wool",
"minecraft:yellow_wool",
"minecraft:lime_wool",
"minecraft:pink_wool",
"minecraft:gray_wool",
"minecraft:light_gray_wool",
"minecraft:cyan_wool",
"minecraft:purple_wool",
"minecraft:blue_wool",
"minecraft:brown_wool",
"minecraft:green_wool",
"minecraft:red_wool",
"minecraft:black_wool"
],
"minecraft:piglin_food": ["minecraft:porkchop", "minecraft:cooked_porkchop"],
"minecraft:piglin_loved": [
"minecraft:gold_ore",
"minecraft:nether_gold_ore",
"minecraft:deepslate_gold_ore",
"minecraft:gold_block",
"minecraft:gilded_blackstone",
"minecraft:light_weighted_pressure_plate",
"minecraft:gold_ingot",
"minecraft:bell",
"minecraft:clock",
"minecraft:golden_carrot",
"minecraft:glistering_melon_slice",
"minecraft:golden_apple",
"minecraft:enchanted_golden_apple",
"minecraft:golden_helmet",
"minecraft:golden_chestplate",
"minecraft:golden_leggings",
"minecraft:golden_boots",
"minecraft:golden_horse_armor",
"minecraft:golden_sword",
"minecraft:golden_pickaxe",
"minecraft:golden_shovel",
"minecraft:golden_axe",
"minecraft:golden_hoe",
"minecraft:raw_gold",
"minecraft:raw_gold_block"
],
"minecraft:piglin_repellents": [
"minecraft:soul_torch",
"minecraft:soul_lantern",
"minecraft:soul_campfire"
],
"minecraft:planks": [
"minecraft:oak_planks",
"minecraft:spruce_planks",
"minecraft:birch_planks",
"minecraft:jungle_planks",
"minecraft:acacia_planks",
"minecraft:dark_oak_planks",
"minecraft:crimson_planks",
"minecraft:warped_planks"
],
"minecraft:rails": [
"minecraft:rail",
"minecraft:powered_rail",
"minecraft:detector_rail",
"minecraft:activator_rail"
],
"minecraft:redstone_ores": ["minecraft:redstone_ore", "minecraft:deepslate_redstone_ore"],
"minecraft:sand": ["minecraft:sand", "minecraft:red_sand"],
"minecraft:saplings": [
"minecraft:oak_sapling",
"minecraft:spruce_sapling",
"minecraft:birch_sapling",
"minecraft:jungle_sapling",
"minecraft:acacia_sapling",
"minecraft:dark_oak_sapling",
"minecraft:azalea",
"minecraft:flowering_azalea"
],
"minecraft:signs": [
"minecraft:oak_sign",
"minecraft:spruce_sign",
"minecraft:birch_sign",
"minecraft:acacia_sign",
"minecraft:jungle_sign",
"minecraft:dark_oak_sign",
"minecraft:crimson_sign",
"minecraft:warped_sign"
],
"minecraft:slabs": [
"minecraft:oak_slab",
"minecraft:spruce_slab",
"minecraft:birch_slab",
"minecraft:jungle_slab",
"minecraft:acacia_slab",
"minecraft:dark_oak_slab",
"minecraft:crimson_slab",
"minecraft:warped_slab",
"minecraft:stone_slab",
"minecraft:smooth_stone_slab",
"minecraft:stone_brick_slab",
"minecraft:sandstone_slab",
"minecraft:purpur_slab",
"minecraft:quartz_slab",
"minecraft:red_sandstone_slab",
"minecraft:brick_slab",
"minecraft:cobblestone_slab",
"minecraft:nether_brick_slab",
"minecraft:petrified_oak_slab",
"minecraft:prismarine_slab",
"minecraft:prismarine_brick_slab",
"minecraft:dark_prismarine_slab",
"minecraft:polished_granite_slab",
"minecraft:smooth_red_sandstone_slab",
"minecraft:mossy_stone_brick_slab",
"minecraft:polished_diorite_slab",
"minecraft:mossy_cobblestone_slab",
"minecraft:end_stone_brick_slab",
"minecraft:smooth_sandstone_slab",
"minecraft:smooth_quartz_slab",
"minecraft:granite_slab",
"minecraft:andesite_slab",
"minecraft:red_nether_brick_slab",
"minecraft:polished_andesite_slab",
"minecraft:diorite_slab",
"minecraft:cut_sandstone_slab",
"minecraft:cut_red_sandstone_slab",
"minecraft:blackstone_slab",
"minecraft:polished_blackstone_brick_slab",
"minecraft:polished_blackstone_slab",
"minecraft:cobbled_deepslate_slab",
"minecraft:polished_deepslate_slab",
"minecraft:deepslate_tile_slab",
"minecraft:deepslate_brick_slab",
"minecraft:waxed_weathered_cut_copper_slab",
"minecraft:waxed_exposed_cut_copper_slab",
"minecraft:waxed_cut_copper_slab",
"minecraft:oxidized_cut_copper_slab",
"minecraft:weathered_cut_copper_slab",
"minecraft:exposed_cut_copper_slab",
"minecraft:cut_copper_slab",
"minecraft:waxed_oxidized_cut_copper_slab"
],
"minecraft:small_flowers": [
"minecraft:dandelion",
"minecraft:poppy",
"minecraft:blue_orchid",
"minecraft:allium",
"minecraft:azure_bluet",
"minecraft:red_tulip",
"minecraft:orange_tulip",
"minecraft:white_tulip",
"minecraft:pink_tulip",
"minecraft:oxeye_daisy",
"minecraft:cornflower",
"minecraft:lily_of_the_valley",
"minecraft:wither_rose"
],
"minecraft:soul_fire_base_blocks": ["minecraft:soul_sand", "minecraft:soul_soil"],
"minecraft:spruce_logs": [
"minecraft:spruce_log",
"minecraft:spruce_wood",
"minecraft:stripped_spruce_log",
"minecraft:stripped_spruce_wood"
],
"minecraft:stairs": [
"minecraft:oak_stairs",
"minecraft:spruce_stairs",
"minecraft:birch_stairs",
"minecraft:jungle_stairs",
"minecraft:acacia_stairs",
"minecraft:dark_oak_stairs",
"minecraft:crimson_stairs",
"minecraft:warped_stairs",
"minecraft:cobblestone_stairs",
"minecraft:sandstone_stairs",
"minecraft:nether_brick_stairs",
"minecraft:stone_brick_stairs",
"minecraft:brick_stairs",
"minecraft:purpur_stairs",
"minecraft:quartz_stairs",
"minecraft:red_sandstone_stairs",
"minecraft:prismarine_brick_stairs",
"minecraft:prismarine_stairs",
"minecraft:dark_prismarine_stairs",
"minecraft:polished_granite_stairs",
"minecraft:smooth_red_sandstone_stairs",
"minecraft:mossy_stone_brick_stairs",
"minecraft:polished_diorite_stairs",
"minecraft:mossy_cobblestone_stairs",
"minecraft:end_stone_brick_stairs",
"minecraft:stone_stairs",
"minecraft:smooth_sandstone_stairs",
"minecraft:smooth_quartz_stairs",
"minecraft:granite_stairs",
"minecraft:andesite_stairs",
"minecraft:red_nether_brick_stairs",
"minecraft:polished_andesite_stairs",
"minecraft:diorite_stairs",
"minecraft:blackstone_stairs",
"minecraft:polished_blackstone_brick_stairs",
"minecraft:polished_blackstone_stairs",
"minecraft:cobbled_deepslate_stairs",
"minecraft:polished_deepslate_stairs",
"minecraft:deepslate_tile_stairs",
"minecraft:deepslate_brick_stairs",
"minecraft:oxidized_cut_copper_stairs",
"minecraft:weathered_cut_copper_stairs",
"minecraft:exposed_cut_copper_stairs",
"minecraft:cut_copper_stairs",
"minecraft:waxed_weathered_cut_copper_stairs",
"minecraft:waxed_exposed_cut_copper_stairs",
"minecraft:waxed_cut_copper_stairs",
"minecraft:waxed_oxidized_cut_copper_stairs"
],
"minecraft:stone_bricks": [
"minecraft:stone_bricks",
"minecraft:mossy_stone_bricks",
"minecraft:cracked_stone_bricks",
"minecraft:chiseled_stone_bricks"
],
"minecraft:stone_crafting_materials": [
"minecraft:cobblestone",
"minecraft:blackstone",
"minecraft:cobbled_deepslate"
],
"minecraft:stone_tool_materials": [
"minecraft:cobblestone",
"minecraft:blackstone",
"minecraft:cobbled_deepslate"
],
"minecraft:tall_flowers": [
"minecraft:sunflower",
"minecraft:lilac",
"minecraft:peony",
"minecraft:rose_bush"
],
"minecraft:trapdoors": [
"minecraft:acacia_trapdoor",
"minecraft:birch_trapdoor",
"minecraft:dark_oak_trapdoor",
"minecraft:jungle_trapdoor",
"minecraft:oak_trapdoor",
"minecraft:spruce_trapdoor",
"minecraft:crimson_trapdoor",
"minecraft:warped_trapdoor",
"minecraft:iron_trapdoor"
],
"minecraft:walls": [
"minecraft:cobblestone_wall",
"minecraft:mossy_cobblestone_wall",
"minecraft:brick_wall",
"minecraft:prismarine_wall",
"minecraft:red_sandstone_wall",
"minecraft:mossy_stone_brick_wall",
"minecraft:granite_wall",
"minecraft:stone_brick_wall",
"minecraft:nether_brick_wall",
"minecraft:andesite_wall",
"minecraft:red_nether_brick_wall",
"minecraft:sandstone_wall",
"minecraft:end_stone_brick_wall",
"minecraft:diorite_wall",
"minecraft:blackstone_wall",
"minecraft:polished_blackstone_brick_wall",
"minecraft:polished_blackstone_wall",
"minecraft:cobbled_deepslate_wall",
"minecraft:polished_deepslate_wall",
"minecraft:deepslate_tile_wall",
"minecraft:deepslate_brick_wall"
],
"minecraft:warped_stems": [
"minecraft:warped_stem",
"minecraft:stripped_warped_stem",
"minecraft:warped_hyphae",
"minecraft:stripped_warped_hyphae"
],
"minecraft:wooden_buttons": [
"minecraft:oak_button",
"minecraft:spruce_button",
"minecraft:birch_button",
"minecraft:jungle_button",
"minecraft:acacia_button",
"minecraft:dark_oak_button",
"minecraft:crimson_button",
"minecraft:warped_button"
],
"minecraft:wooden_doors": [
"minecraft:oak_door",
"minecraft:spruce_door",
"minecraft:birch_door",
"minecraft:jungle_door",
"minecraft:acacia_door",
"minecraft:dark_oak_door",
"minecraft:crimson_door",
"minecraft:warped_door"
],
"minecraft:wooden_fences": [
"minecraft:oak_fence",
"minecraft:acacia_fence",
"minecraft:dark_oak_fence",
"minecraft:spruce_fence",
"minecraft:birch_fence",
"minecraft:jungle_fence",
"minecraft:crimson_fence",
"minecraft:warped_fence"
],
"minecraft:wooden_pressure_plates": [
"minecraft:oak_pressure_plate",
"minecraft:spruce_pressure_plate",
"minecraft:birch_pressure_plate",
"minecraft:jungle_pressure_plate",
"minecraft:acacia_pressure_plate",
"minecraft:dark_oak_pressure_plate",
"minecraft:crimson_pressure_plate",
"minecraft:warped_pressure_plate"
],
"minecraft:wooden_slabs": [
"minecraft:oak_slab",
"minecraft:spruce_slab",
"minecraft:birch_slab",
"minecraft:jungle_slab",
"minecraft:acacia_slab",
"minecraft:dark_oak_slab",
"minecraft:crimson_slab",
"minecraft:warped_slab"
],
"minecraft:wooden_stairs": [
"minecraft:oak_stairs",
"minecraft:spruce_stairs",
"minecraft:birch_stairs",
"minecraft:jungle_stairs",
"minecraft:acacia_stairs",
"minecraft:dark_oak_stairs",
"minecraft:crimson_stairs",
"minecraft:warped_stairs"
],
"minecraft:wooden_trapdoors": [
"minecraft:acacia_trapdoor",
"minecraft:birch_trapdoor",
"minecraft:dark_oak_trapdoor",
"minecraft:jungle_trapdoor",
"minecraft:oak_trapdoor",
"minecraft:spruce_trapdoor",
"minecraft:crimson_trapdoor",
"minecraft:warped_trapdoor"
],
"minecraft:wool": [
"minecraft:white_wool",
"minecraft:orange_wool",
"minecraft:magenta_wool",
"minecraft:light_blue_wool",
"minecraft:yellow_wool",
"minecraft:lime_wool",
"minecraft:pink_wool",
"minecraft:gray_wool",
"minecraft:light_gray_wool",
"minecraft:cyan_wool",
"minecraft:purple_wool",
"minecraft:blue_wool",
"minecraft:brown_wool",
"minecraft:green_wool",
"minecraft:red_wool",
"minecraft:black_wool"
]
}

View File

@ -0,0 +1,745 @@
{
"minecraft:acacia_logs": [
"minecraft:acacia_log",
"minecraft:acacia_wood",
"minecraft:stripped_acacia_log",
"minecraft:stripped_acacia_wood"
],
"minecraft:anvil": ["minecraft:anvil", "minecraft:chipped_anvil", "minecraft:damaged_anvil"],
"minecraft:arrows": ["minecraft:arrow", "minecraft:tipped_arrow", "minecraft:spectral_arrow"],
"minecraft:axolotl_tempt_items": ["minecraft:tropical_fish_bucket"],
"minecraft:banners": [
"minecraft:white_banner",
"minecraft:orange_banner",
"minecraft:magenta_banner",
"minecraft:light_blue_banner",
"minecraft:yellow_banner",
"minecraft:lime_banner",
"minecraft:pink_banner",
"minecraft:gray_banner",
"minecraft:light_gray_banner",
"minecraft:cyan_banner",
"minecraft:purple_banner",
"minecraft:blue_banner",
"minecraft:brown_banner",
"minecraft:green_banner",
"minecraft:red_banner",
"minecraft:black_banner"
],
"minecraft:beacon_payment_items": [
"minecraft:netherite_ingot",
"minecraft:emerald",
"minecraft:diamond",
"minecraft:gold_ingot",
"minecraft:iron_ingot"
],
"minecraft:beds": [
"minecraft:red_bed",
"minecraft:black_bed",
"minecraft:blue_bed",
"minecraft:brown_bed",
"minecraft:cyan_bed",
"minecraft:gray_bed",
"minecraft:green_bed",
"minecraft:light_blue_bed",
"minecraft:light_gray_bed",
"minecraft:lime_bed",
"minecraft:magenta_bed",
"minecraft:orange_bed",
"minecraft:pink_bed",
"minecraft:purple_bed",
"minecraft:white_bed",
"minecraft:yellow_bed"
],
"minecraft:birch_logs": [
"minecraft:birch_log",
"minecraft:birch_wood",
"minecraft:stripped_birch_log",
"minecraft:stripped_birch_wood"
],
"minecraft:boats": [
"minecraft:oak_boat",
"minecraft:spruce_boat",
"minecraft:birch_boat",
"minecraft:jungle_boat",
"minecraft:acacia_boat",
"minecraft:dark_oak_boat"
],
"minecraft:buttons": [
"minecraft:oak_button",
"minecraft:spruce_button",
"minecraft:birch_button",
"minecraft:jungle_button",
"minecraft:acacia_button",
"minecraft:dark_oak_button",
"minecraft:crimson_button",
"minecraft:warped_button",
"minecraft:stone_button",
"minecraft:polished_blackstone_button"
],
"minecraft:candles": [
"minecraft:candle",
"minecraft:white_candle",
"minecraft:orange_candle",
"minecraft:magenta_candle",
"minecraft:light_blue_candle",
"minecraft:yellow_candle",
"minecraft:lime_candle",
"minecraft:pink_candle",
"minecraft:gray_candle",
"minecraft:light_gray_candle",
"minecraft:cyan_candle",
"minecraft:purple_candle",
"minecraft:blue_candle",
"minecraft:brown_candle",
"minecraft:green_candle",
"minecraft:red_candle",
"minecraft:black_candle"
],
"minecraft:carpets": [
"minecraft:white_carpet",
"minecraft:orange_carpet",
"minecraft:magenta_carpet",
"minecraft:light_blue_carpet",
"minecraft:yellow_carpet",
"minecraft:lime_carpet",
"minecraft:pink_carpet",
"minecraft:gray_carpet",
"minecraft:light_gray_carpet",
"minecraft:cyan_carpet",
"minecraft:purple_carpet",
"minecraft:blue_carpet",
"minecraft:brown_carpet",
"minecraft:green_carpet",
"minecraft:red_carpet",
"minecraft:black_carpet"
],
"minecraft:cluster_max_harvestables": [
"minecraft:diamond_pickaxe",
"minecraft:golden_pickaxe",
"minecraft:iron_pickaxe",
"minecraft:netherite_pickaxe",
"minecraft:stone_pickaxe",
"minecraft:wooden_pickaxe"
],
"minecraft:coal_ores": ["minecraft:coal_ore", "minecraft:deepslate_coal_ore"],
"minecraft:coals": ["minecraft:coal", "minecraft:charcoal"],
"minecraft:copper_ores": ["minecraft:copper_ore", "minecraft:deepslate_copper_ore"],
"minecraft:creeper_drop_music_discs": [
"minecraft:music_disc_13",
"minecraft:music_disc_cat",
"minecraft:music_disc_blocks",
"minecraft:music_disc_chirp",
"minecraft:music_disc_far",
"minecraft:music_disc_mall",
"minecraft:music_disc_mellohi",
"minecraft:music_disc_stal",
"minecraft:music_disc_strad",
"minecraft:music_disc_ward",
"minecraft:music_disc_11",
"minecraft:music_disc_wait"
],
"minecraft:crimson_stems": [
"minecraft:crimson_stem",
"minecraft:stripped_crimson_stem",
"minecraft:crimson_hyphae",
"minecraft:stripped_crimson_hyphae"
],
"minecraft:dark_oak_logs": [
"minecraft:dark_oak_log",
"minecraft:dark_oak_wood",
"minecraft:stripped_dark_oak_log",
"minecraft:stripped_dark_oak_wood"
],
"minecraft:diamond_ores": ["minecraft:diamond_ore", "minecraft:deepslate_diamond_ore"],
"minecraft:dirt": [
"minecraft:dirt",
"minecraft:grass_block",
"minecraft:podzol",
"minecraft:coarse_dirt",
"minecraft:mycelium",
"minecraft:rooted_dirt",
"minecraft:moss_block"
],
"minecraft:doors": [
"minecraft:oak_door",
"minecraft:spruce_door",
"minecraft:birch_door",
"minecraft:jungle_door",
"minecraft:acacia_door",
"minecraft:dark_oak_door",
"minecraft:crimson_door",
"minecraft:warped_door",
"minecraft:iron_door"
],
"minecraft:emerald_ores": ["minecraft:emerald_ore", "minecraft:deepslate_emerald_ore"],
"minecraft:fences": [
"minecraft:oak_fence",
"minecraft:acacia_fence",
"minecraft:dark_oak_fence",
"minecraft:spruce_fence",
"minecraft:birch_fence",
"minecraft:jungle_fence",
"minecraft:crimson_fence",
"minecraft:warped_fence",
"minecraft:nether_brick_fence"
],
"minecraft:fishes": [
"minecraft:cod",
"minecraft:cooked_cod",
"minecraft:salmon",
"minecraft:cooked_salmon",
"minecraft:pufferfish",
"minecraft:tropical_fish"
],
"minecraft:flowers": [
"minecraft:dandelion",
"minecraft:poppy",
"minecraft:blue_orchid",
"minecraft:allium",
"minecraft:azure_bluet",
"minecraft:red_tulip",
"minecraft:orange_tulip",
"minecraft:white_tulip",
"minecraft:pink_tulip",
"minecraft:oxeye_daisy",
"minecraft:cornflower",
"minecraft:lily_of_the_valley",
"minecraft:wither_rose",
"minecraft:sunflower",
"minecraft:lilac",
"minecraft:peony",
"minecraft:rose_bush",
"minecraft:flowering_azalea_leaves",
"minecraft:flowering_azalea"
],
"minecraft:fox_food": ["minecraft:sweet_berries", "minecraft:glow_berries"],
"minecraft:freeze_immune_wearables": [
"minecraft:leather_boots",
"minecraft:leather_leggings",
"minecraft:leather_chestplate",
"minecraft:leather_helmet",
"minecraft:leather_horse_armor"
],
"minecraft:gold_ores": [
"minecraft:gold_ore",
"minecraft:nether_gold_ore",
"minecraft:deepslate_gold_ore"
],
"minecraft:ignored_by_piglin_babies": ["minecraft:leather"],
"minecraft:iron_ores": ["minecraft:iron_ore", "minecraft:deepslate_iron_ore"],
"minecraft:jungle_logs": [
"minecraft:jungle_log",
"minecraft:jungle_wood",
"minecraft:stripped_jungle_log",
"minecraft:stripped_jungle_wood"
],
"minecraft:lapis_ores": ["minecraft:lapis_ore", "minecraft:deepslate_lapis_ore"],
"minecraft:leaves": [
"minecraft:jungle_leaves",
"minecraft:oak_leaves",
"minecraft:spruce_leaves",
"minecraft:dark_oak_leaves",
"minecraft:acacia_leaves",
"minecraft:birch_leaves",
"minecraft:azalea_leaves",
"minecraft:flowering_azalea_leaves"
],
"minecraft:lectern_books": ["minecraft:written_book", "minecraft:writable_book"],
"minecraft:logs": [
"minecraft:dark_oak_log",
"minecraft:dark_oak_wood",
"minecraft:stripped_dark_oak_log",
"minecraft:stripped_dark_oak_wood",
"minecraft:oak_log",
"minecraft:oak_wood",
"minecraft:stripped_oak_log",
"minecraft:stripped_oak_wood",
"minecraft:acacia_log",
"minecraft:acacia_wood",
"minecraft:stripped_acacia_log",
"minecraft:stripped_acacia_wood",
"minecraft:birch_log",
"minecraft:birch_wood",
"minecraft:stripped_birch_log",
"minecraft:stripped_birch_wood",
"minecraft:jungle_log",
"minecraft:jungle_wood",
"minecraft:stripped_jungle_log",
"minecraft:stripped_jungle_wood",
"minecraft:spruce_log",
"minecraft:spruce_wood",
"minecraft:stripped_spruce_log",
"minecraft:stripped_spruce_wood",
"minecraft:crimson_stem",
"minecraft:stripped_crimson_stem",
"minecraft:crimson_hyphae",
"minecraft:stripped_crimson_hyphae",
"minecraft:warped_stem",
"minecraft:stripped_warped_stem",
"minecraft:warped_hyphae",
"minecraft:stripped_warped_hyphae"
],
"minecraft:logs_that_burn": [
"minecraft:dark_oak_log",
"minecraft:dark_oak_wood",
"minecraft:stripped_dark_oak_log",
"minecraft:stripped_dark_oak_wood",
"minecraft:oak_log",
"minecraft:oak_wood",
"minecraft:stripped_oak_log",
"minecraft:stripped_oak_wood",
"minecraft:acacia_log",
"minecraft:acacia_wood",
"minecraft:stripped_acacia_log",
"minecraft:stripped_acacia_wood",
"minecraft:birch_log",
"minecraft:birch_wood",
"minecraft:stripped_birch_log",
"minecraft:stripped_birch_wood",
"minecraft:jungle_log",
"minecraft:jungle_wood",
"minecraft:stripped_jungle_log",
"minecraft:stripped_jungle_wood",
"minecraft:spruce_log",
"minecraft:spruce_wood",
"minecraft:stripped_spruce_log",
"minecraft:stripped_spruce_wood"
],
"minecraft:music_discs": [
"minecraft:music_disc_13",
"minecraft:music_disc_cat",
"minecraft:music_disc_blocks",
"minecraft:music_disc_chirp",
"minecraft:music_disc_far",
"minecraft:music_disc_mall",
"minecraft:music_disc_mellohi",
"minecraft:music_disc_stal",
"minecraft:music_disc_strad",
"minecraft:music_disc_ward",
"minecraft:music_disc_11",
"minecraft:music_disc_wait",
"minecraft:music_disc_pigstep",
"minecraft:music_disc_otherside"
],
"minecraft:non_flammable_wood": [
"minecraft:warped_stem",
"minecraft:stripped_warped_stem",
"minecraft:warped_hyphae",
"minecraft:stripped_warped_hyphae",
"minecraft:crimson_stem",
"minecraft:stripped_crimson_stem",
"minecraft:crimson_hyphae",
"minecraft:stripped_crimson_hyphae",
"minecraft:crimson_planks",
"minecraft:warped_planks",
"minecraft:crimson_slab",
"minecraft:warped_slab",
"minecraft:crimson_pressure_plate",
"minecraft:warped_pressure_plate",
"minecraft:crimson_fence",
"minecraft:warped_fence",
"minecraft:crimson_trapdoor",
"minecraft:warped_trapdoor",
"minecraft:crimson_fence_gate",
"minecraft:warped_fence_gate",
"minecraft:crimson_stairs",
"minecraft:warped_stairs",
"minecraft:crimson_button",
"minecraft:warped_button",
"minecraft:crimson_door",
"minecraft:warped_door",
"minecraft:crimson_sign",
"minecraft:warped_sign"
],
"minecraft:oak_logs": [
"minecraft:oak_log",
"minecraft:oak_wood",
"minecraft:stripped_oak_log",
"minecraft:stripped_oak_wood"
],
"minecraft:occludes_vibration_signals": [
"minecraft:white_wool",
"minecraft:orange_wool",
"minecraft:magenta_wool",
"minecraft:light_blue_wool",
"minecraft:yellow_wool",
"minecraft:lime_wool",
"minecraft:pink_wool",
"minecraft:gray_wool",
"minecraft:light_gray_wool",
"minecraft:cyan_wool",
"minecraft:purple_wool",
"minecraft:blue_wool",
"minecraft:brown_wool",
"minecraft:green_wool",
"minecraft:red_wool",
"minecraft:black_wool"
],
"minecraft:piglin_food": ["minecraft:porkchop", "minecraft:cooked_porkchop"],
"minecraft:piglin_loved": [
"minecraft:gold_ore",
"minecraft:nether_gold_ore",
"minecraft:deepslate_gold_ore",
"minecraft:gold_block",
"minecraft:gilded_blackstone",
"minecraft:light_weighted_pressure_plate",
"minecraft:gold_ingot",
"minecraft:bell",
"minecraft:clock",
"minecraft:golden_carrot",
"minecraft:glistering_melon_slice",
"minecraft:golden_apple",
"minecraft:enchanted_golden_apple",
"minecraft:golden_helmet",
"minecraft:golden_chestplate",
"minecraft:golden_leggings",
"minecraft:golden_boots",
"minecraft:golden_horse_armor",
"minecraft:golden_sword",
"minecraft:golden_pickaxe",
"minecraft:golden_shovel",
"minecraft:golden_axe",
"minecraft:golden_hoe",
"minecraft:raw_gold",
"minecraft:raw_gold_block"
],
"minecraft:piglin_repellents": [
"minecraft:soul_torch",
"minecraft:soul_lantern",
"minecraft:soul_campfire"
],
"minecraft:planks": [
"minecraft:oak_planks",
"minecraft:spruce_planks",
"minecraft:birch_planks",
"minecraft:jungle_planks",
"minecraft:acacia_planks",
"minecraft:dark_oak_planks",
"minecraft:crimson_planks",
"minecraft:warped_planks"
],
"minecraft:rails": [
"minecraft:rail",
"minecraft:powered_rail",
"minecraft:detector_rail",
"minecraft:activator_rail"
],
"minecraft:redstone_ores": ["minecraft:redstone_ore", "minecraft:deepslate_redstone_ore"],
"minecraft:sand": ["minecraft:sand", "minecraft:red_sand"],
"minecraft:saplings": [
"minecraft:oak_sapling",
"minecraft:spruce_sapling",
"minecraft:birch_sapling",
"minecraft:jungle_sapling",
"minecraft:acacia_sapling",
"minecraft:dark_oak_sapling",
"minecraft:azalea",
"minecraft:flowering_azalea"
],
"minecraft:signs": [
"minecraft:oak_sign",
"minecraft:spruce_sign",
"minecraft:birch_sign",
"minecraft:acacia_sign",
"minecraft:jungle_sign",
"minecraft:dark_oak_sign",
"minecraft:crimson_sign",
"minecraft:warped_sign"
],
"minecraft:slabs": [
"minecraft:oak_slab",
"minecraft:spruce_slab",
"minecraft:birch_slab",
"minecraft:jungle_slab",
"minecraft:acacia_slab",
"minecraft:dark_oak_slab",
"minecraft:crimson_slab",
"minecraft:warped_slab",
"minecraft:stone_slab",
"minecraft:smooth_stone_slab",
"minecraft:stone_brick_slab",
"minecraft:sandstone_slab",
"minecraft:purpur_slab",
"minecraft:quartz_slab",
"minecraft:red_sandstone_slab",
"minecraft:brick_slab",
"minecraft:cobblestone_slab",
"minecraft:nether_brick_slab",
"minecraft:petrified_oak_slab",
"minecraft:prismarine_slab",
"minecraft:prismarine_brick_slab",
"minecraft:dark_prismarine_slab",
"minecraft:polished_granite_slab",
"minecraft:smooth_red_sandstone_slab",
"minecraft:mossy_stone_brick_slab",
"minecraft:polished_diorite_slab",
"minecraft:mossy_cobblestone_slab",
"minecraft:end_stone_brick_slab",
"minecraft:smooth_sandstone_slab",
"minecraft:smooth_quartz_slab",
"minecraft:granite_slab",
"minecraft:andesite_slab",
"minecraft:red_nether_brick_slab",
"minecraft:polished_andesite_slab",
"minecraft:diorite_slab",
"minecraft:cut_sandstone_slab",
"minecraft:cut_red_sandstone_slab",
"minecraft:blackstone_slab",
"minecraft:polished_blackstone_brick_slab",
"minecraft:polished_blackstone_slab",
"minecraft:cobbled_deepslate_slab",
"minecraft:polished_deepslate_slab",
"minecraft:deepslate_tile_slab",
"minecraft:deepslate_brick_slab",
"minecraft:waxed_weathered_cut_copper_slab",
"minecraft:waxed_exposed_cut_copper_slab",
"minecraft:waxed_cut_copper_slab",
"minecraft:oxidized_cut_copper_slab",
"minecraft:weathered_cut_copper_slab",
"minecraft:exposed_cut_copper_slab",
"minecraft:cut_copper_slab",
"minecraft:waxed_oxidized_cut_copper_slab"
],
"minecraft:small_flowers": [
"minecraft:dandelion",
"minecraft:poppy",
"minecraft:blue_orchid",
"minecraft:allium",
"minecraft:azure_bluet",
"minecraft:red_tulip",
"minecraft:orange_tulip",
"minecraft:white_tulip",
"minecraft:pink_tulip",
"minecraft:oxeye_daisy",
"minecraft:cornflower",
"minecraft:lily_of_the_valley",
"minecraft:wither_rose"
],
"minecraft:soul_fire_base_blocks": ["minecraft:soul_sand", "minecraft:soul_soil"],
"minecraft:spruce_logs": [
"minecraft:spruce_log",
"minecraft:spruce_wood",
"minecraft:stripped_spruce_log",
"minecraft:stripped_spruce_wood"
],
"minecraft:stairs": [
"minecraft:oak_stairs",
"minecraft:spruce_stairs",
"minecraft:birch_stairs",
"minecraft:jungle_stairs",
"minecraft:acacia_stairs",
"minecraft:dark_oak_stairs",
"minecraft:crimson_stairs",
"minecraft:warped_stairs",
"minecraft:cobblestone_stairs",
"minecraft:sandstone_stairs",
"minecraft:nether_brick_stairs",
"minecraft:stone_brick_stairs",
"minecraft:brick_stairs",
"minecraft:purpur_stairs",
"minecraft:quartz_stairs",
"minecraft:red_sandstone_stairs",
"minecraft:prismarine_brick_stairs",
"minecraft:prismarine_stairs",
"minecraft:dark_prismarine_stairs",
"minecraft:polished_granite_stairs",
"minecraft:smooth_red_sandstone_stairs",
"minecraft:mossy_stone_brick_stairs",
"minecraft:polished_diorite_stairs",
"minecraft:mossy_cobblestone_stairs",
"minecraft:end_stone_brick_stairs",
"minecraft:stone_stairs",
"minecraft:smooth_sandstone_stairs",
"minecraft:smooth_quartz_stairs",
"minecraft:granite_stairs",
"minecraft:andesite_stairs",
"minecraft:red_nether_brick_stairs",
"minecraft:polished_andesite_stairs",
"minecraft:diorite_stairs",
"minecraft:blackstone_stairs",
"minecraft:polished_blackstone_brick_stairs",
"minecraft:polished_blackstone_stairs",
"minecraft:cobbled_deepslate_stairs",
"minecraft:polished_deepslate_stairs",
"minecraft:deepslate_tile_stairs",
"minecraft:deepslate_brick_stairs",
"minecraft:oxidized_cut_copper_stairs",
"minecraft:weathered_cut_copper_stairs",
"minecraft:exposed_cut_copper_stairs",
"minecraft:cut_copper_stairs",
"minecraft:waxed_weathered_cut_copper_stairs",
"minecraft:waxed_exposed_cut_copper_stairs",
"minecraft:waxed_cut_copper_stairs",
"minecraft:waxed_oxidized_cut_copper_stairs"
],
"minecraft:stone_bricks": [
"minecraft:stone_bricks",
"minecraft:mossy_stone_bricks",
"minecraft:cracked_stone_bricks",
"minecraft:chiseled_stone_bricks"
],
"minecraft:stone_crafting_materials": [
"minecraft:cobblestone",
"minecraft:blackstone",
"minecraft:cobbled_deepslate"
],
"minecraft:stone_tool_materials": [
"minecraft:cobblestone",
"minecraft:blackstone",
"minecraft:cobbled_deepslate"
],
"minecraft:tall_flowers": [
"minecraft:sunflower",
"minecraft:lilac",
"minecraft:peony",
"minecraft:rose_bush"
],
"minecraft:terracotta": [
"minecraft:terracotta",
"minecraft:white_terracotta",
"minecraft:orange_terracotta",
"minecraft:magenta_terracotta",
"minecraft:light_blue_terracotta",
"minecraft:yellow_terracotta",
"minecraft:lime_terracotta",
"minecraft:pink_terracotta",
"minecraft:gray_terracotta",
"minecraft:light_gray_terracotta",
"minecraft:cyan_terracotta",
"minecraft:purple_terracotta",
"minecraft:blue_terracotta",
"minecraft:brown_terracotta",
"minecraft:green_terracotta",
"minecraft:red_terracotta",
"minecraft:black_terracotta"
],
"minecraft:trapdoors": [
"minecraft:acacia_trapdoor",
"minecraft:birch_trapdoor",
"minecraft:dark_oak_trapdoor",
"minecraft:jungle_trapdoor",
"minecraft:oak_trapdoor",
"minecraft:spruce_trapdoor",
"minecraft:crimson_trapdoor",
"minecraft:warped_trapdoor",
"minecraft:iron_trapdoor"
],
"minecraft:walls": [
"minecraft:cobblestone_wall",
"minecraft:mossy_cobblestone_wall",
"minecraft:brick_wall",
"minecraft:prismarine_wall",
"minecraft:red_sandstone_wall",
"minecraft:mossy_stone_brick_wall",
"minecraft:granite_wall",
"minecraft:stone_brick_wall",
"minecraft:nether_brick_wall",
"minecraft:andesite_wall",
"minecraft:red_nether_brick_wall",
"minecraft:sandstone_wall",
"minecraft:end_stone_brick_wall",
"minecraft:diorite_wall",
"minecraft:blackstone_wall",
"minecraft:polished_blackstone_brick_wall",
"minecraft:polished_blackstone_wall",
"minecraft:cobbled_deepslate_wall",
"minecraft:polished_deepslate_wall",
"minecraft:deepslate_tile_wall",
"minecraft:deepslate_brick_wall"
],
"minecraft:warped_stems": [
"minecraft:warped_stem",
"minecraft:stripped_warped_stem",
"minecraft:warped_hyphae",
"minecraft:stripped_warped_hyphae"
],
"minecraft:wooden_buttons": [
"minecraft:oak_button",
"minecraft:spruce_button",
"minecraft:birch_button",
"minecraft:jungle_button",
"minecraft:acacia_button",
"minecraft:dark_oak_button",
"minecraft:crimson_button",
"minecraft:warped_button"
],
"minecraft:wooden_doors": [
"minecraft:oak_door",
"minecraft:spruce_door",
"minecraft:birch_door",
"minecraft:jungle_door",
"minecraft:acacia_door",
"minecraft:dark_oak_door",
"minecraft:crimson_door",
"minecraft:warped_door"
],
"minecraft:wooden_fences": [
"minecraft:oak_fence",
"minecraft:acacia_fence",
"minecraft:dark_oak_fence",
"minecraft:spruce_fence",
"minecraft:birch_fence",
"minecraft:jungle_fence",
"minecraft:crimson_fence",
"minecraft:warped_fence"
],
"minecraft:wooden_pressure_plates": [
"minecraft:oak_pressure_plate",
"minecraft:spruce_pressure_plate",
"minecraft:birch_pressure_plate",
"minecraft:jungle_pressure_plate",
"minecraft:acacia_pressure_plate",
"minecraft:dark_oak_pressure_plate",
"minecraft:crimson_pressure_plate",
"minecraft:warped_pressure_plate"
],
"minecraft:wooden_slabs": [
"minecraft:oak_slab",
"minecraft:spruce_slab",
"minecraft:birch_slab",
"minecraft:jungle_slab",
"minecraft:acacia_slab",
"minecraft:dark_oak_slab",
"minecraft:crimson_slab",
"minecraft:warped_slab"
],
"minecraft:wooden_stairs": [
"minecraft:oak_stairs",
"minecraft:spruce_stairs",
"minecraft:birch_stairs",
"minecraft:jungle_stairs",
"minecraft:acacia_stairs",
"minecraft:dark_oak_stairs",
"minecraft:crimson_stairs",
"minecraft:warped_stairs"
],
"minecraft:wooden_trapdoors": [
"minecraft:acacia_trapdoor",
"minecraft:birch_trapdoor",
"minecraft:dark_oak_trapdoor",
"minecraft:jungle_trapdoor",
"minecraft:oak_trapdoor",
"minecraft:spruce_trapdoor",
"minecraft:crimson_trapdoor",
"minecraft:warped_trapdoor"
],
"minecraft:wool": [
"minecraft:white_wool",
"minecraft:orange_wool",
"minecraft:magenta_wool",
"minecraft:light_blue_wool",
"minecraft:yellow_wool",
"minecraft:lime_wool",
"minecraft:pink_wool",
"minecraft:gray_wool",
"minecraft:light_gray_wool",
"minecraft:cyan_wool",
"minecraft:purple_wool",
"minecraft:blue_wool",
"minecraft:brown_wool",
"minecraft:green_wool",
"minecraft:red_wool",
"minecraft:black_wool"
]
}

View File

@ -0,0 +1,876 @@
{
"minecraft:acacia_logs": [
"minecraft:acacia_log",
"minecraft:acacia_wood",
"minecraft:stripped_acacia_log",
"minecraft:stripped_acacia_wood"
],
"minecraft:anvil": ["minecraft:anvil", "minecraft:chipped_anvil", "minecraft:damaged_anvil"],
"minecraft:arrows": ["minecraft:arrow", "minecraft:tipped_arrow", "minecraft:spectral_arrow"],
"minecraft:axolotl_tempt_items": ["minecraft:tropical_fish_bucket"],
"minecraft:banners": [
"minecraft:white_banner",
"minecraft:orange_banner",
"minecraft:magenta_banner",
"minecraft:light_blue_banner",
"minecraft:yellow_banner",
"minecraft:lime_banner",
"minecraft:pink_banner",
"minecraft:gray_banner",
"minecraft:light_gray_banner",
"minecraft:cyan_banner",
"minecraft:purple_banner",
"minecraft:blue_banner",
"minecraft:brown_banner",
"minecraft:green_banner",
"minecraft:red_banner",
"minecraft:black_banner"
],
"minecraft:beacon_payment_items": [
"minecraft:netherite_ingot",
"minecraft:emerald",
"minecraft:diamond",
"minecraft:gold_ingot",
"minecraft:iron_ingot"
],
"minecraft:beds": [
"minecraft:red_bed",
"minecraft:black_bed",
"minecraft:blue_bed",
"minecraft:brown_bed",
"minecraft:cyan_bed",
"minecraft:gray_bed",
"minecraft:green_bed",
"minecraft:light_blue_bed",
"minecraft:light_gray_bed",
"minecraft:lime_bed",
"minecraft:magenta_bed",
"minecraft:orange_bed",
"minecraft:pink_bed",
"minecraft:purple_bed",
"minecraft:white_bed",
"minecraft:yellow_bed"
],
"minecraft:birch_logs": [
"minecraft:birch_log",
"minecraft:birch_wood",
"minecraft:stripped_birch_log",
"minecraft:stripped_birch_wood"
],
"minecraft:boats": [
"minecraft:oak_boat",
"minecraft:spruce_boat",
"minecraft:birch_boat",
"minecraft:jungle_boat",
"minecraft:acacia_boat",
"minecraft:dark_oak_boat",
"minecraft:mangrove_boat",
"minecraft:oak_chest_boat",
"minecraft:spruce_chest_boat",
"minecraft:birch_chest_boat",
"minecraft:jungle_chest_boat",
"minecraft:acacia_chest_boat",
"minecraft:dark_oak_chest_boat",
"minecraft:mangrove_chest_boat"
],
"minecraft:buttons": [
"minecraft:oak_button",
"minecraft:spruce_button",
"minecraft:birch_button",
"minecraft:jungle_button",
"minecraft:acacia_button",
"minecraft:dark_oak_button",
"minecraft:crimson_button",
"minecraft:warped_button",
"minecraft:mangrove_button",
"minecraft:stone_button",
"minecraft:polished_blackstone_button"
],
"minecraft:candles": [
"minecraft:candle",
"minecraft:white_candle",
"minecraft:orange_candle",
"minecraft:magenta_candle",
"minecraft:light_blue_candle",
"minecraft:yellow_candle",
"minecraft:lime_candle",
"minecraft:pink_candle",
"minecraft:gray_candle",
"minecraft:light_gray_candle",
"minecraft:cyan_candle",
"minecraft:purple_candle",
"minecraft:blue_candle",
"minecraft:brown_candle",
"minecraft:green_candle",
"minecraft:red_candle",
"minecraft:black_candle"
],
"minecraft:chest_boats": [
"minecraft:oak_chest_boat",
"minecraft:spruce_chest_boat",
"minecraft:birch_chest_boat",
"minecraft:jungle_chest_boat",
"minecraft:acacia_chest_boat",
"minecraft:dark_oak_chest_boat",
"minecraft:mangrove_chest_boat"
],
"minecraft:cluster_max_harvestables": [
"minecraft:diamond_pickaxe",
"minecraft:golden_pickaxe",
"minecraft:iron_pickaxe",
"minecraft:netherite_pickaxe",
"minecraft:stone_pickaxe",
"minecraft:wooden_pickaxe"
],
"minecraft:coal_ores": ["minecraft:coal_ore", "minecraft:deepslate_coal_ore"],
"minecraft:coals": ["minecraft:coal", "minecraft:charcoal"],
"minecraft:compasses": ["minecraft:compass", "minecraft:recovery_compass"],
"minecraft:completes_find_tree_tutorial": [
"minecraft:dark_oak_log",
"minecraft:dark_oak_wood",
"minecraft:stripped_dark_oak_log",
"minecraft:stripped_dark_oak_wood",
"minecraft:oak_log",
"minecraft:oak_wood",
"minecraft:stripped_oak_log",
"minecraft:stripped_oak_wood",
"minecraft:acacia_log",
"minecraft:acacia_wood",
"minecraft:stripped_acacia_log",
"minecraft:stripped_acacia_wood",
"minecraft:birch_log",
"minecraft:birch_wood",
"minecraft:stripped_birch_log",
"minecraft:stripped_birch_wood",
"minecraft:jungle_log",
"minecraft:jungle_wood",
"minecraft:stripped_jungle_log",
"minecraft:stripped_jungle_wood",
"minecraft:spruce_log",
"minecraft:spruce_wood",
"minecraft:stripped_spruce_log",
"minecraft:stripped_spruce_wood",
"minecraft:mangrove_log",
"minecraft:mangrove_wood",
"minecraft:stripped_mangrove_log",
"minecraft:stripped_mangrove_wood",
"minecraft:crimson_stem",
"minecraft:stripped_crimson_stem",
"minecraft:crimson_hyphae",
"minecraft:stripped_crimson_hyphae",
"minecraft:warped_stem",
"minecraft:stripped_warped_stem",
"minecraft:warped_hyphae",
"minecraft:stripped_warped_hyphae",
"minecraft:jungle_leaves",
"minecraft:oak_leaves",
"minecraft:spruce_leaves",
"minecraft:dark_oak_leaves",
"minecraft:acacia_leaves",
"minecraft:birch_leaves",
"minecraft:azalea_leaves",
"minecraft:flowering_azalea_leaves",
"minecraft:mangrove_leaves",
"minecraft:nether_wart_block",
"minecraft:warped_wart_block"
],
"minecraft:copper_ores": ["minecraft:copper_ore", "minecraft:deepslate_copper_ore"],
"minecraft:creeper_drop_music_discs": [
"minecraft:music_disc_13",
"minecraft:music_disc_cat",
"minecraft:music_disc_blocks",
"minecraft:music_disc_chirp",
"minecraft:music_disc_far",
"minecraft:music_disc_mall",
"minecraft:music_disc_mellohi",
"minecraft:music_disc_stal",
"minecraft:music_disc_strad",
"minecraft:music_disc_ward",
"minecraft:music_disc_11",
"minecraft:music_disc_wait"
],
"minecraft:crimson_stems": [
"minecraft:crimson_stem",
"minecraft:stripped_crimson_stem",
"minecraft:crimson_hyphae",
"minecraft:stripped_crimson_hyphae"
],
"minecraft:dampens_vibrations": [
"minecraft:white_wool",
"minecraft:orange_wool",
"minecraft:magenta_wool",
"minecraft:light_blue_wool",
"minecraft:yellow_wool",
"minecraft:lime_wool",
"minecraft:pink_wool",
"minecraft:gray_wool",
"minecraft:light_gray_wool",
"minecraft:cyan_wool",
"minecraft:purple_wool",
"minecraft:blue_wool",
"minecraft:brown_wool",
"minecraft:green_wool",
"minecraft:red_wool",
"minecraft:black_wool",
"minecraft:white_carpet",
"minecraft:orange_carpet",
"minecraft:magenta_carpet",
"minecraft:light_blue_carpet",
"minecraft:yellow_carpet",
"minecraft:lime_carpet",
"minecraft:pink_carpet",
"minecraft:gray_carpet",
"minecraft:light_gray_carpet",
"minecraft:cyan_carpet",
"minecraft:purple_carpet",
"minecraft:blue_carpet",
"minecraft:brown_carpet",
"minecraft:green_carpet",
"minecraft:red_carpet",
"minecraft:black_carpet"
],
"minecraft:dark_oak_logs": [
"minecraft:dark_oak_log",
"minecraft:dark_oak_wood",
"minecraft:stripped_dark_oak_log",
"minecraft:stripped_dark_oak_wood"
],
"minecraft:diamond_ores": ["minecraft:diamond_ore", "minecraft:deepslate_diamond_ore"],
"minecraft:dirt": [
"minecraft:dirt",
"minecraft:grass_block",
"minecraft:podzol",
"minecraft:coarse_dirt",
"minecraft:mycelium",
"minecraft:rooted_dirt",
"minecraft:moss_block",
"minecraft:mud",
"minecraft:muddy_mangrove_roots"
],
"minecraft:doors": [
"minecraft:oak_door",
"minecraft:spruce_door",
"minecraft:birch_door",
"minecraft:jungle_door",
"minecraft:acacia_door",
"minecraft:dark_oak_door",
"minecraft:crimson_door",
"minecraft:warped_door",
"minecraft:mangrove_door",
"minecraft:iron_door"
],
"minecraft:emerald_ores": ["minecraft:emerald_ore", "minecraft:deepslate_emerald_ore"],
"minecraft:fences": [
"minecraft:oak_fence",
"minecraft:acacia_fence",
"minecraft:dark_oak_fence",
"minecraft:spruce_fence",
"minecraft:birch_fence",
"minecraft:jungle_fence",
"minecraft:crimson_fence",
"minecraft:warped_fence",
"minecraft:mangrove_fence",
"minecraft:nether_brick_fence"
],
"minecraft:fishes": [
"minecraft:cod",
"minecraft:cooked_cod",
"minecraft:salmon",
"minecraft:cooked_salmon",
"minecraft:pufferfish",
"minecraft:tropical_fish"
],
"minecraft:flowers": [
"minecraft:dandelion",
"minecraft:poppy",
"minecraft:blue_orchid",
"minecraft:allium",
"minecraft:azure_bluet",
"minecraft:red_tulip",
"minecraft:orange_tulip",
"minecraft:white_tulip",
"minecraft:pink_tulip",
"minecraft:oxeye_daisy",
"minecraft:cornflower",
"minecraft:lily_of_the_valley",
"minecraft:wither_rose",
"minecraft:sunflower",
"minecraft:lilac",
"minecraft:peony",
"minecraft:rose_bush",
"minecraft:flowering_azalea_leaves",
"minecraft:flowering_azalea",
"minecraft:mangrove_propagule"
],
"minecraft:fox_food": ["minecraft:sweet_berries", "minecraft:glow_berries"],
"minecraft:freeze_immune_wearables": [
"minecraft:leather_boots",
"minecraft:leather_leggings",
"minecraft:leather_chestplate",
"minecraft:leather_helmet",
"minecraft:leather_horse_armor"
],
"minecraft:gold_ores": [
"minecraft:gold_ore",
"minecraft:nether_gold_ore",
"minecraft:deepslate_gold_ore"
],
"minecraft:ignored_by_piglin_babies": ["minecraft:leather"],
"minecraft:iron_ores": ["minecraft:iron_ore", "minecraft:deepslate_iron_ore"],
"minecraft:jungle_logs": [
"minecraft:jungle_log",
"minecraft:jungle_wood",
"minecraft:stripped_jungle_log",
"minecraft:stripped_jungle_wood"
],
"minecraft:lapis_ores": ["minecraft:lapis_ore", "minecraft:deepslate_lapis_ore"],
"minecraft:leaves": [
"minecraft:jungle_leaves",
"minecraft:oak_leaves",
"minecraft:spruce_leaves",
"minecraft:dark_oak_leaves",
"minecraft:acacia_leaves",
"minecraft:birch_leaves",
"minecraft:azalea_leaves",
"minecraft:flowering_azalea_leaves",
"minecraft:mangrove_leaves"
],
"minecraft:lectern_books": ["minecraft:written_book", "minecraft:writable_book"],
"minecraft:logs": [
"minecraft:dark_oak_log",
"minecraft:dark_oak_wood",
"minecraft:stripped_dark_oak_log",
"minecraft:stripped_dark_oak_wood",
"minecraft:oak_log",
"minecraft:oak_wood",
"minecraft:stripped_oak_log",
"minecraft:stripped_oak_wood",
"minecraft:acacia_log",
"minecraft:acacia_wood",
"minecraft:stripped_acacia_log",
"minecraft:stripped_acacia_wood",
"minecraft:birch_log",
"minecraft:birch_wood",
"minecraft:stripped_birch_log",
"minecraft:stripped_birch_wood",
"minecraft:jungle_log",
"minecraft:jungle_wood",
"minecraft:stripped_jungle_log",
"minecraft:stripped_jungle_wood",
"minecraft:spruce_log",
"minecraft:spruce_wood",
"minecraft:stripped_spruce_log",
"minecraft:stripped_spruce_wood",
"minecraft:mangrove_log",
"minecraft:mangrove_wood",
"minecraft:stripped_mangrove_log",
"minecraft:stripped_mangrove_wood",
"minecraft:crimson_stem",
"minecraft:stripped_crimson_stem",
"minecraft:crimson_hyphae",
"minecraft:stripped_crimson_hyphae",
"minecraft:warped_stem",
"minecraft:stripped_warped_stem",
"minecraft:warped_hyphae",
"minecraft:stripped_warped_hyphae"
],
"minecraft:logs_that_burn": [
"minecraft:dark_oak_log",
"minecraft:dark_oak_wood",
"minecraft:stripped_dark_oak_log",
"minecraft:stripped_dark_oak_wood",
"minecraft:oak_log",
"minecraft:oak_wood",
"minecraft:stripped_oak_log",
"minecraft:stripped_oak_wood",
"minecraft:acacia_log",
"minecraft:acacia_wood",
"minecraft:stripped_acacia_log",
"minecraft:stripped_acacia_wood",
"minecraft:birch_log",
"minecraft:birch_wood",
"minecraft:stripped_birch_log",
"minecraft:stripped_birch_wood",
"minecraft:jungle_log",
"minecraft:jungle_wood",
"minecraft:stripped_jungle_log",
"minecraft:stripped_jungle_wood",
"minecraft:spruce_log",
"minecraft:spruce_wood",
"minecraft:stripped_spruce_log",
"minecraft:stripped_spruce_wood",
"minecraft:mangrove_log",
"minecraft:mangrove_wood",
"minecraft:stripped_mangrove_log",
"minecraft:stripped_mangrove_wood"
],
"minecraft:mangrove_logs": [
"minecraft:mangrove_log",
"minecraft:mangrove_wood",
"minecraft:stripped_mangrove_log",
"minecraft:stripped_mangrove_wood"
],
"minecraft:music_discs": [
"minecraft:music_disc_13",
"minecraft:music_disc_cat",
"minecraft:music_disc_blocks",
"minecraft:music_disc_chirp",
"minecraft:music_disc_far",
"minecraft:music_disc_mall",
"minecraft:music_disc_mellohi",
"minecraft:music_disc_stal",
"minecraft:music_disc_strad",
"minecraft:music_disc_ward",
"minecraft:music_disc_11",
"minecraft:music_disc_wait",
"minecraft:music_disc_pigstep",
"minecraft:music_disc_otherside",
"minecraft:music_disc_5"
],
"minecraft:non_flammable_wood": [
"minecraft:warped_stem",
"minecraft:stripped_warped_stem",
"minecraft:warped_hyphae",
"minecraft:stripped_warped_hyphae",
"minecraft:crimson_stem",
"minecraft:stripped_crimson_stem",
"minecraft:crimson_hyphae",
"minecraft:stripped_crimson_hyphae",
"minecraft:crimson_planks",
"minecraft:warped_planks",
"minecraft:crimson_slab",
"minecraft:warped_slab",
"minecraft:crimson_pressure_plate",
"minecraft:warped_pressure_plate",
"minecraft:crimson_fence",
"minecraft:warped_fence",
"minecraft:crimson_trapdoor",
"minecraft:warped_trapdoor",
"minecraft:crimson_fence_gate",
"minecraft:warped_fence_gate",
"minecraft:crimson_stairs",
"minecraft:warped_stairs",
"minecraft:crimson_button",
"minecraft:warped_button",
"minecraft:crimson_door",
"minecraft:warped_door",
"minecraft:crimson_sign",
"minecraft:warped_sign"
],
"minecraft:oak_logs": [
"minecraft:oak_log",
"minecraft:oak_wood",
"minecraft:stripped_oak_log",
"minecraft:stripped_oak_wood"
],
"minecraft:overworld_natural_logs": [
"minecraft:acacia_log",
"minecraft:birch_log",
"minecraft:oak_log",
"minecraft:jungle_log",
"minecraft:spruce_log",
"minecraft:dark_oak_log",
"minecraft:mangrove_log"
],
"minecraft:piglin_food": ["minecraft:porkchop", "minecraft:cooked_porkchop"],
"minecraft:piglin_loved": [
"minecraft:gold_ore",
"minecraft:nether_gold_ore",
"minecraft:deepslate_gold_ore",
"minecraft:gold_block",
"minecraft:gilded_blackstone",
"minecraft:light_weighted_pressure_plate",
"minecraft:gold_ingot",
"minecraft:bell",
"minecraft:clock",
"minecraft:golden_carrot",
"minecraft:glistering_melon_slice",
"minecraft:golden_apple",
"minecraft:enchanted_golden_apple",
"minecraft:golden_helmet",
"minecraft:golden_chestplate",
"minecraft:golden_leggings",
"minecraft:golden_boots",
"minecraft:golden_horse_armor",
"minecraft:golden_sword",
"minecraft:golden_pickaxe",
"minecraft:golden_shovel",
"minecraft:golden_axe",
"minecraft:golden_hoe",
"minecraft:raw_gold",
"minecraft:raw_gold_block"
],
"minecraft:piglin_repellents": [
"minecraft:soul_torch",
"minecraft:soul_lantern",
"minecraft:soul_campfire"
],
"minecraft:planks": [
"minecraft:oak_planks",
"minecraft:spruce_planks",
"minecraft:birch_planks",
"minecraft:jungle_planks",
"minecraft:acacia_planks",
"minecraft:dark_oak_planks",
"minecraft:crimson_planks",
"minecraft:warped_planks",
"minecraft:mangrove_planks"
],
"minecraft:rails": [
"minecraft:rail",
"minecraft:powered_rail",
"minecraft:detector_rail",
"minecraft:activator_rail"
],
"minecraft:redstone_ores": ["minecraft:redstone_ore", "minecraft:deepslate_redstone_ore"],
"minecraft:sand": ["minecraft:sand", "minecraft:red_sand"],
"minecraft:saplings": [
"minecraft:oak_sapling",
"minecraft:spruce_sapling",
"minecraft:birch_sapling",
"minecraft:jungle_sapling",
"minecraft:acacia_sapling",
"minecraft:dark_oak_sapling",
"minecraft:azalea",
"minecraft:flowering_azalea",
"minecraft:mangrove_propagule"
],
"minecraft:signs": [
"minecraft:oak_sign",
"minecraft:spruce_sign",
"minecraft:birch_sign",
"minecraft:acacia_sign",
"minecraft:jungle_sign",
"minecraft:dark_oak_sign",
"minecraft:crimson_sign",
"minecraft:warped_sign",
"minecraft:mangrove_sign"
],
"minecraft:slabs": [
"minecraft:oak_slab",
"minecraft:spruce_slab",
"minecraft:birch_slab",
"minecraft:jungle_slab",
"minecraft:acacia_slab",
"minecraft:dark_oak_slab",
"minecraft:crimson_slab",
"minecraft:warped_slab",
"minecraft:mangrove_slab",
"minecraft:stone_slab",
"minecraft:smooth_stone_slab",
"minecraft:stone_brick_slab",
"minecraft:sandstone_slab",
"minecraft:purpur_slab",
"minecraft:quartz_slab",
"minecraft:red_sandstone_slab",
"minecraft:brick_slab",
"minecraft:cobblestone_slab",
"minecraft:nether_brick_slab",
"minecraft:petrified_oak_slab",
"minecraft:prismarine_slab",
"minecraft:prismarine_brick_slab",
"minecraft:dark_prismarine_slab",
"minecraft:polished_granite_slab",
"minecraft:smooth_red_sandstone_slab",
"minecraft:mossy_stone_brick_slab",
"minecraft:polished_diorite_slab",
"minecraft:mossy_cobblestone_slab",
"minecraft:end_stone_brick_slab",
"minecraft:smooth_sandstone_slab",
"minecraft:smooth_quartz_slab",
"minecraft:granite_slab",
"minecraft:andesite_slab",
"minecraft:red_nether_brick_slab",
"minecraft:polished_andesite_slab",
"minecraft:diorite_slab",
"minecraft:cut_sandstone_slab",
"minecraft:cut_red_sandstone_slab",
"minecraft:blackstone_slab",
"minecraft:polished_blackstone_brick_slab",
"minecraft:polished_blackstone_slab",
"minecraft:cobbled_deepslate_slab",
"minecraft:polished_deepslate_slab",
"minecraft:deepslate_tile_slab",
"minecraft:deepslate_brick_slab",
"minecraft:waxed_weathered_cut_copper_slab",
"minecraft:waxed_exposed_cut_copper_slab",
"minecraft:waxed_cut_copper_slab",
"minecraft:oxidized_cut_copper_slab",
"minecraft:weathered_cut_copper_slab",
"minecraft:exposed_cut_copper_slab",
"minecraft:cut_copper_slab",
"minecraft:waxed_oxidized_cut_copper_slab",
"minecraft:mud_brick_slab"
],
"minecraft:small_flowers": [
"minecraft:dandelion",
"minecraft:poppy",
"minecraft:blue_orchid",
"minecraft:allium",
"minecraft:azure_bluet",
"minecraft:red_tulip",
"minecraft:orange_tulip",
"minecraft:white_tulip",
"minecraft:pink_tulip",
"minecraft:oxeye_daisy",
"minecraft:cornflower",
"minecraft:lily_of_the_valley",
"minecraft:wither_rose"
],
"minecraft:soul_fire_base_blocks": ["minecraft:soul_sand", "minecraft:soul_soil"],
"minecraft:spruce_logs": [
"minecraft:spruce_log",
"minecraft:spruce_wood",
"minecraft:stripped_spruce_log",
"minecraft:stripped_spruce_wood"
],
"minecraft:stairs": [
"minecraft:oak_stairs",
"minecraft:spruce_stairs",
"minecraft:birch_stairs",
"minecraft:jungle_stairs",
"minecraft:acacia_stairs",
"minecraft:dark_oak_stairs",
"minecraft:crimson_stairs",
"minecraft:warped_stairs",
"minecraft:mangrove_stairs",
"minecraft:cobblestone_stairs",
"minecraft:sandstone_stairs",
"minecraft:nether_brick_stairs",
"minecraft:stone_brick_stairs",
"minecraft:brick_stairs",
"minecraft:purpur_stairs",
"minecraft:quartz_stairs",
"minecraft:red_sandstone_stairs",
"minecraft:prismarine_brick_stairs",
"minecraft:prismarine_stairs",
"minecraft:dark_prismarine_stairs",
"minecraft:polished_granite_stairs",
"minecraft:smooth_red_sandstone_stairs",
"minecraft:mossy_stone_brick_stairs",
"minecraft:polished_diorite_stairs",
"minecraft:mossy_cobblestone_stairs",
"minecraft:end_stone_brick_stairs",
"minecraft:stone_stairs",
"minecraft:smooth_sandstone_stairs",
"minecraft:smooth_quartz_stairs",
"minecraft:granite_stairs",
"minecraft:andesite_stairs",
"minecraft:red_nether_brick_stairs",
"minecraft:polished_andesite_stairs",
"minecraft:diorite_stairs",
"minecraft:blackstone_stairs",
"minecraft:polished_blackstone_brick_stairs",
"minecraft:polished_blackstone_stairs",
"minecraft:cobbled_deepslate_stairs",
"minecraft:polished_deepslate_stairs",
"minecraft:deepslate_tile_stairs",
"minecraft:deepslate_brick_stairs",
"minecraft:oxidized_cut_copper_stairs",
"minecraft:weathered_cut_copper_stairs",
"minecraft:exposed_cut_copper_stairs",
"minecraft:cut_copper_stairs",
"minecraft:waxed_weathered_cut_copper_stairs",
"minecraft:waxed_exposed_cut_copper_stairs",
"minecraft:waxed_cut_copper_stairs",
"minecraft:waxed_oxidized_cut_copper_stairs",
"minecraft:mud_brick_stairs"
],
"minecraft:stone_bricks": [
"minecraft:stone_bricks",
"minecraft:mossy_stone_bricks",
"minecraft:cracked_stone_bricks",
"minecraft:chiseled_stone_bricks"
],
"minecraft:stone_crafting_materials": [
"minecraft:cobblestone",
"minecraft:blackstone",
"minecraft:cobbled_deepslate"
],
"minecraft:stone_tool_materials": [
"minecraft:cobblestone",
"minecraft:blackstone",
"minecraft:cobbled_deepslate"
],
"minecraft:tall_flowers": [
"minecraft:sunflower",
"minecraft:lilac",
"minecraft:peony",
"minecraft:rose_bush"
],
"minecraft:terracotta": [
"minecraft:terracotta",
"minecraft:white_terracotta",
"minecraft:orange_terracotta",
"minecraft:magenta_terracotta",
"minecraft:light_blue_terracotta",
"minecraft:yellow_terracotta",
"minecraft:lime_terracotta",
"minecraft:pink_terracotta",
"minecraft:gray_terracotta",
"minecraft:light_gray_terracotta",
"minecraft:cyan_terracotta",
"minecraft:purple_terracotta",
"minecraft:blue_terracotta",
"minecraft:brown_terracotta",
"minecraft:green_terracotta",
"minecraft:red_terracotta",
"minecraft:black_terracotta"
],
"minecraft:trapdoors": [
"minecraft:acacia_trapdoor",
"minecraft:birch_trapdoor",
"minecraft:dark_oak_trapdoor",
"minecraft:jungle_trapdoor",
"minecraft:oak_trapdoor",
"minecraft:spruce_trapdoor",
"minecraft:crimson_trapdoor",
"minecraft:warped_trapdoor",
"minecraft:mangrove_trapdoor",
"minecraft:iron_trapdoor"
],
"minecraft:walls": [
"minecraft:cobblestone_wall",
"minecraft:mossy_cobblestone_wall",
"minecraft:brick_wall",
"minecraft:prismarine_wall",
"minecraft:red_sandstone_wall",
"minecraft:mossy_stone_brick_wall",
"minecraft:granite_wall",
"minecraft:stone_brick_wall",
"minecraft:nether_brick_wall",
"minecraft:andesite_wall",
"minecraft:red_nether_brick_wall",
"minecraft:sandstone_wall",
"minecraft:end_stone_brick_wall",
"minecraft:diorite_wall",
"minecraft:blackstone_wall",
"minecraft:polished_blackstone_brick_wall",
"minecraft:polished_blackstone_wall",
"minecraft:cobbled_deepslate_wall",
"minecraft:polished_deepslate_wall",
"minecraft:deepslate_tile_wall",
"minecraft:deepslate_brick_wall",
"minecraft:mud_brick_wall"
],
"minecraft:warped_stems": [
"minecraft:warped_stem",
"minecraft:stripped_warped_stem",
"minecraft:warped_hyphae",
"minecraft:stripped_warped_hyphae"
],
"minecraft:wart_blocks": ["minecraft:nether_wart_block", "minecraft:warped_wart_block"],
"minecraft:wooden_buttons": [
"minecraft:oak_button",
"minecraft:spruce_button",
"minecraft:birch_button",
"minecraft:jungle_button",
"minecraft:acacia_button",
"minecraft:dark_oak_button",
"minecraft:crimson_button",
"minecraft:warped_button",
"minecraft:mangrove_button"
],
"minecraft:wooden_doors": [
"minecraft:oak_door",
"minecraft:spruce_door",
"minecraft:birch_door",
"minecraft:jungle_door",
"minecraft:acacia_door",
"minecraft:dark_oak_door",
"minecraft:crimson_door",
"minecraft:warped_door",
"minecraft:mangrove_door"
],
"minecraft:wooden_fences": [
"minecraft:oak_fence",
"minecraft:acacia_fence",
"minecraft:dark_oak_fence",
"minecraft:spruce_fence",
"minecraft:birch_fence",
"minecraft:jungle_fence",
"minecraft:crimson_fence",
"minecraft:warped_fence",
"minecraft:mangrove_fence"
],
"minecraft:wooden_pressure_plates": [
"minecraft:oak_pressure_plate",
"minecraft:spruce_pressure_plate",
"minecraft:birch_pressure_plate",
"minecraft:jungle_pressure_plate",
"minecraft:acacia_pressure_plate",
"minecraft:dark_oak_pressure_plate",
"minecraft:crimson_pressure_plate",
"minecraft:warped_pressure_plate",
"minecraft:mangrove_pressure_plate"
],
"minecraft:wooden_slabs": [
"minecraft:oak_slab",
"minecraft:spruce_slab",
"minecraft:birch_slab",
"minecraft:jungle_slab",
"minecraft:acacia_slab",
"minecraft:dark_oak_slab",
"minecraft:crimson_slab",
"minecraft:warped_slab",
"minecraft:mangrove_slab"
],
"minecraft:wooden_stairs": [
"minecraft:oak_stairs",
"minecraft:spruce_stairs",
"minecraft:birch_stairs",
"minecraft:jungle_stairs",
"minecraft:acacia_stairs",
"minecraft:dark_oak_stairs",
"minecraft:crimson_stairs",
"minecraft:warped_stairs",
"minecraft:mangrove_stairs"
],
"minecraft:wooden_trapdoors": [
"minecraft:acacia_trapdoor",
"minecraft:birch_trapdoor",
"minecraft:dark_oak_trapdoor",
"minecraft:jungle_trapdoor",
"minecraft:oak_trapdoor",
"minecraft:spruce_trapdoor",
"minecraft:crimson_trapdoor",
"minecraft:warped_trapdoor",
"minecraft:mangrove_trapdoor"
],
"minecraft:wool": [
"minecraft:white_wool",
"minecraft:orange_wool",
"minecraft:magenta_wool",
"minecraft:light_blue_wool",
"minecraft:yellow_wool",
"minecraft:lime_wool",
"minecraft:pink_wool",
"minecraft:gray_wool",
"minecraft:light_gray_wool",
"minecraft:cyan_wool",
"minecraft:purple_wool",
"minecraft:blue_wool",
"minecraft:brown_wool",
"minecraft:green_wool",
"minecraft:red_wool",
"minecraft:black_wool"
],
"minecraft:wool_carpets": [
"minecraft:white_carpet",
"minecraft:orange_carpet",
"minecraft:magenta_carpet",
"minecraft:light_blue_carpet",
"minecraft:yellow_carpet",
"minecraft:lime_carpet",
"minecraft:pink_carpet",
"minecraft:gray_carpet",
"minecraft:light_gray_carpet",
"minecraft:cyan_carpet",
"minecraft:purple_carpet",
"minecraft:blue_carpet",
"minecraft:brown_carpet",
"minecraft:green_carpet",
"minecraft:red_carpet",
"minecraft:black_carpet"
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,51 @@
import type { SlotValue } from './types.ts'
export const RESULT_COUNT_MAX = 64
export type ResultCountValue = Extract<SlotValue, { kind: 'item' } | { kind: 'custom_item' }>
export function isResultValue(value: SlotValue | undefined): value is ResultCountValue {
if (!value) return false
return value.kind === 'item' || value.kind === 'custom_item'
}
export function nextResultCount(current: number, deltaY: number) {
if (deltaY === 0) return current
const next = current + (deltaY < 0 ? 1 : -1)
return Math.min(RESULT_COUNT_MAX, Math.max(1, next))
}
export function countFontSize(iconSize: number) {
return Math.max(8, Math.round(iconSize * 0.32))
}
export function countInset(iconSize: number) {
return Math.max(2, Math.round(iconSize * 0.02))
}
export function countShadow(iconSize: number) {
const offset = Math.max(1, Math.round(iconSize * 0.02))
return `${offset}px ${offset}px 0 #000`
}
export function drawCountOnCanvas(
context: CanvasRenderingContext2D,
count: number,
iconSize: number,
iconX: number,
iconY: number,
) {
context.fillStyle = '#fff'
context.font = `bold ${countFontSize(iconSize)}px sans-serif`
context.textAlign = 'right'
context.textBaseline = 'bottom'
context.shadowColor = '#000'
context.shadowOffsetX = Math.max(1, Math.round(iconSize * 0.01))
context.shadowOffsetY = Math.max(1, Math.round(iconSize * 0.01))
context.fillText(
String(count),
iconX + iconSize - countInset(iconSize),
iconY + iconSize - countInset(iconSize),
)
context.shadowColor = 'transparent'
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,84 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { unzipSync } from 'fflate'
import {
createDatapackBlob,
createDatapackDescription,
createDatapackFileName,
createDatapackFiles,
createPackMcmeta,
} from './datapack.ts'
test('creates pack.mcmeta with legacy and ranged pack formats', () => {
assert.equal(JSON.parse(createPackMcmeta(48)).pack.pack_format, 48)
assert.deepEqual(JSON.parse(createPackMcmeta([107, 1])).pack, {
description: 'Axolotl Recipe Generator',
min_format: [107, 1],
max_format: [107, 1],
})
})
test('creates a datapack file name with a timestamp', () => {
assert.equal(
createDatapackFileName('1.21', new Date('2026-01-02T03:04:05')),
'axolotl-recipes-1.21-20260102-030405.zip',
)
})
test('creates a description listing recipe product names in brackets', () => {
assert.equal(
createDatapackDescription(['橡木活板门', '闪长岩台阶']),
'Axolotl Recipe Generator\n[橡木活板门] [闪长岩台阶]',
)
})
test('builds datapack files with versioned recipe and tag directories', async () => {
const files = createDatapackFiles(
'1.21',
[{ name: 'iron_bars', json: { type: 'minecraft:crafting_shaped' } }],
[
{
namespace: 'crafting',
id: 'my_tag',
values: ['minecraft:oak_planks', '#minecraft:planks'],
},
],
)
assert.deepEqual(
files.map((file) => file.path),
[
'pack.mcmeta',
'pack.png',
'data/crafting/recipe/iron_bars.json',
'data/crafting/tags/item/my_tag.json',
],
)
const blob = createDatapackBlob(files)
const archive = unzipSync(new Uint8Array(await blob.arrayBuffer()))
assert.ok(archive['pack.png']?.length)
assert.deepEqual(
JSON.parse(new TextDecoder().decode(archive['data/crafting/recipe/iron_bars.json'])),
{
type: 'minecraft:crafting_shaped',
},
)
assert.deepEqual(
JSON.parse(new TextDecoder().decode(archive['data/crafting/tags/item/my_tag.json'])),
{ replace: false, values: ['minecraft:oak_planks', '#minecraft:planks'] },
)
})
test('uses recipes directory before 1.21', () => {
const files = createDatapackFiles(
'1.20',
[{ name: 'iron_bars', json: { type: 'minecraft:crafting_shaped' } }],
[],
)
assert.ok(files.some((file) => file.path === 'data/crafting/recipes/iron_bars.json'))
})
test('rejects datapack export for 1.12', () => {
assert.throws(() => createDatapackFiles('1.12', [], []))
})

View File

@ -0,0 +1,172 @@
import { save } from '@tauri-apps/plugin-dialog'
import { writeFile, writeTextFile } from '@tauri-apps/plugin-fs'
import { strToU8, zipSync } from 'fflate'
import { DATAPACK_ICON_BASE64 } from './datapack-icon.ts'
import { parseIdentifier, rawId } from './identifier.ts'
import type { JavaVersionId } from './types.ts'
import { getJavaVersionMeta } from './versions.ts'
export type PackFile = {
path: string
content: string | Uint8Array
}
export type DatapackRecipe = {
name: string
json: object
}
export type DatapackTag = {
namespace: string
id: string
values: string[]
}
export type DatapackSaveSource = PackFile[] | Blob
const PACK_DESCRIPTION = 'Axolotl Recipe Generator'
function formatDatapackTimestamp(date: Date): string {
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(
date.getHours(),
)}${pad(date.getMinutes())}${pad(date.getSeconds())}`
}
export function createDatapackFileName(version: JavaVersionId, date = new Date()): string {
return `axolotl-recipes-${version}-${formatDatapackTimestamp(date)}.zip`
}
export function createDatapackDescription(productNames: readonly string[]): string {
const names = productNames
.map((name) => name.trim())
.filter(Boolean)
.map((name) => `[${name}]`)
.join(' ')
return names ? `${PACK_DESCRIPTION}\n${names}` : PACK_DESCRIPTION
}
function base64ToUint8Array(base64: string): Uint8Array {
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index)
}
return bytes
}
let packIconBytes: Uint8Array | null = null
function getPackIconBytes(): Uint8Array {
if (!packIconBytes) packIconBytes = base64ToUint8Array(DATAPACK_ICON_BASE64)
return packIconBytes
}
export function createPackMcmeta(
packFormat: number | [number, number],
description = PACK_DESCRIPTION,
): string {
const pack = Array.isArray(packFormat)
? { min_format: packFormat, max_format: packFormat }
: { pack_format: packFormat }
return JSON.stringify({ pack: { description, ...pack } }, null, 2)
}
export function createDatapackFiles(
version: JavaVersionId,
recipes: DatapackRecipe[],
tags: DatapackTag[],
description = PACK_DESCRIPTION,
): PackFile[] {
const meta = getJavaVersionMeta(version)
if (!meta.packFormat || !meta.recipeDir || !meta.tagDir) {
throw new Error(`Datapack export is not available for ${version}`)
}
const files: PackFile[] = [
{ path: 'pack.mcmeta', content: createPackMcmeta(meta.packFormat, description) },
{ path: 'pack.png', content: getPackIconBytes() },
]
for (const recipe of recipes) {
files.push({
path: `data/crafting/${meta.recipeDir}/${recipe.name}.json`,
content: JSON.stringify(recipe.json, null, 2),
})
}
for (const tag of tags) {
files.push({
path: `data/${tag.namespace}/${meta.tagDir}/${tag.id}.json`,
content: JSON.stringify({ replace: false, values: tag.values }, null, 2),
})
}
return files
}
export function createDatapackBlob(files: PackFile[]): Blob {
const record: Record<string, Uint8Array> = {}
for (const file of files) {
record[file.path] = typeof file.content === 'string' ? strToU8(file.content) : file.content
}
const zipped = zipSync(record)
const source = zipped.buffer.slice(
zipped.byteOffset,
zipped.byteOffset + zipped.byteLength,
) as ArrayBuffer
const bytes = new Uint8Array(source)
return new Blob([bytes], { type: 'application/zip' })
}
export function createTagFiles(customTags: { id: string; values: string[] }[]): DatapackTag[] {
return customTags.flatMap((tag) => {
const ref = parseIdentifier(tag.id)
return [
{
namespace: ref.namespace,
id: ref.id,
values: tag.values,
},
]
})
}
export function downloadBlob(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.click()
setTimeout(() => URL.revokeObjectURL(url), 1_000)
}
export function downloadJson(value: object, fileName: string): void {
downloadBlob(new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' }), fileName)
}
export async function saveJsonFile(value: object, defaultFileName: string): Promise<string | null> {
const path = await save({
defaultPath: defaultFileName,
filters: [{ name: 'Minecraft recipe JSON', extensions: ['json'] }],
})
if (!path) return null
await writeTextFile(path, JSON.stringify(value, null, 2))
return path
}
export async function saveDatapackAs(
source: DatapackSaveSource,
defaultFileName: string,
): Promise<string | null> {
const path = await save({
defaultPath: defaultFileName,
filters: [{ name: 'Minecraft datapack', extensions: ['zip'] }],
})
if (!path) return null
const blob = source instanceof Blob ? source : createDatapackBlob(source)
await writeFile(path, new Uint8Array(await blob.arrayBuffer()))
return path
}
export function customTagRawId(tag: { id: string }): string {
return rawId(parseIdentifier(tag.id))
}

View File

@ -0,0 +1,55 @@
import { parseIdentifier, rawId } from './identifier.ts'
import type { RecipeSlotContext, SlotValue } from './types.ts'
export type SlotDisplay = {
label: string
texture: string | null
isTag: boolean
count?: number
}
export function getSlotDisplay(
value: SlotValue | undefined,
ctx: RecipeSlotContext,
): SlotDisplay | null {
if (!value) return null
if (value.kind === 'item') {
const item = ctx.itemsById[value.id]
return {
label: item?.name ?? rawId(parseIdentifier(value.id)),
texture: item?.texture ?? null,
isTag: false,
count: value.count,
}
}
if (value.kind === 'custom_item') {
const item = ctx.customItemsByUid[value.uid]
return item
? { label: item.name, texture: item.texture || null, isTag: false, count: value.count }
: { label: value.uid, texture: null, isTag: false, count: value.count }
}
if (value.kind === 'vanilla_tag') {
const members = ctx.vanillaTags[value.id] ?? []
const firstTexture = members
.map((id) => ctx.itemsById[id]?.texture)
.find((texture): texture is string => Boolean(texture))
return {
label: `#${value.id}`,
texture: firstTexture ?? null,
isTag: true,
}
}
const tag = ctx.customTagsByUid[value.uid]
return tag
? { label: `#${tag.id}`, texture: null, isTag: true }
: { label: value.uid, texture: null, isTag: true }
}
export function getSlotTextureHash(
value: SlotValue | undefined,
ctx: RecipeSlotContext,
): string | null {
const display = getSlotDisplay(value, ctx)
if (display?.texture) return display.texture
return null
}

View File

@ -0,0 +1,49 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createJavaFormatter } from './formatter.ts'
import { parseIdentifier } from './identifier.ts'
test('formats Java 1.12 ingredients and results with legacy data values', () => {
const formatter = createJavaFormatter('1.12')
const stone = parseIdentifier('minecraft:stone:1')
assert.deepEqual(formatter.ingredient(stone, false), { item: 'minecraft:stone', data: 1 })
assert.deepEqual(formatter.result(stone, 2), {
item: 'minecraft:stone',
data: 1,
count: 2,
})
assert.equal(formatter.typeName('crafting_shaped'), 'crafting_shaped')
})
test('formats Java 1.13 ingredients, tags, and string cooking results', () => {
const formatter = createJavaFormatter('1.13')
const iron = parseIdentifier('minecraft:iron_ingot')
assert.deepEqual(formatter.ingredient(iron, false), { item: 'minecraft:iron_ingot' })
assert.deepEqual(formatter.ingredient(iron, true), { tag: 'minecraft:iron_ingot' })
assert.deepEqual(formatter.result(iron), { item: 'minecraft:iron_ingot' })
assert.equal(formatter.cookingResult(iron), 'minecraft:iron_ingot')
assert.equal(formatter.typeName('smelting'), 'smelting')
})
test('namespaces recipe types from Java 1.14 onwards', () => {
const formatter = createJavaFormatter('1.14')
assert.equal(formatter.typeName('smelting'), 'minecraft:smelting')
})
test('switches results to id objects from Java 1.20', () => {
const formatter = createJavaFormatter('1.20')
const iron = parseIdentifier('minecraft:iron_ingot')
assert.deepEqual(formatter.result(iron, 3), { id: 'minecraft:iron_ingot', count: 3 })
assert.deepEqual(formatter.cookingResult(iron), { id: 'minecraft:iron_ingot' })
assert.deepEqual(formatter.stonecutterResult(iron, 4), {
result: { id: 'minecraft:iron_ingot', count: 4 },
})
})
test('uses string ingredients and tag references from Java 1.21.2', () => {
const formatter = createJavaFormatter('1.21.2')
const iron = parseIdentifier('minecraft:iron_ingot')
assert.equal(formatter.ingredient(iron, false), 'minecraft:iron_ingot')
assert.equal(formatter.ingredient(iron, true), '#minecraft:iron_ingot')
})

View File

@ -0,0 +1,67 @@
import type { ItemRef } from './identifier.ts'
import { rawId } from './identifier.ts'
import type { JavaVersionId } from './types.ts'
import { isVersionAtLeast } from './versions.ts'
export interface JavaRecipeFormatter {
typeName(base: string): string
ingredient(ref: ItemRef, isTag: boolean): unknown
result(ref: ItemRef, count?: number): Record<string, unknown>
cookingResult(ref: ItemRef, count?: number): unknown
stonecutterResult(ref: ItemRef, count?: number): Record<string, unknown>
}
function withCount<T extends Record<string, unknown>>(
value: T,
count: number | undefined,
): T | (T & { count: number }) {
return typeof count === 'number' && count > 0 ? { ...value, count } : value
}
const bareType = (base: string) => base
const namespacedType = (base: string) => `minecraft:${base}`
const v112: JavaRecipeFormatter = {
typeName: bareType,
ingredient: (ref) => ({
item: rawId(ref),
...(ref.data !== undefined ? { data: ref.data } : {}),
}),
result: (ref, count) =>
withCount({ item: rawId(ref), ...(ref.data !== undefined ? { data: ref.data } : {}) }, count),
cookingResult: (ref) => rawId(ref),
stonecutterResult: (ref, count) => ({ result: rawId(ref), count: count ?? 1 }),
}
const v113: JavaRecipeFormatter = {
typeName: bareType,
ingredient: (ref, isTag) => (isTag ? { tag: rawId(ref) } : { item: rawId(ref) }),
result: (ref, count) => withCount({ item: rawId(ref) }, count),
cookingResult: (ref) => rawId(ref),
stonecutterResult: (ref, count) => ({ result: rawId(ref), count: count ?? 1 }),
}
const v114: JavaRecipeFormatter = {
...v113,
typeName: namespacedType,
}
const v120: JavaRecipeFormatter = {
...v114,
result: (ref, count) => withCount({ id: rawId(ref) }, count),
cookingResult: (ref, count) => withCount({ id: rawId(ref) }, count),
stonecutterResult: (ref, count) => ({ result: withCount({ id: rawId(ref) }, count) }),
}
const v1212: JavaRecipeFormatter = {
...v120,
ingredient: (ref, isTag) => (isTag ? `#${rawId(ref)}` : rawId(ref)),
}
export function createJavaFormatter(version: JavaVersionId): JavaRecipeFormatter {
if (version === '1.12') return v112
if (version === '1.13') return v113
if (isVersionAtLeast(version, '1.21.2')) return v1212
if (isVersionAtLeast(version, '1.20')) return v120
return v114
}

View File

@ -0,0 +1,36 @@
export type ItemRef = {
namespace: string
id: string
data?: number
}
export function parseIdentifier(raw: string, data?: number): ItemRef {
const firstColon = raw.indexOf(':')
if (firstColon < 0) {
return { namespace: 'minecraft', id: raw, ...(data !== undefined ? { data } : {}) }
}
const namespace = raw.slice(0, firstColon)
const rest = raw.slice(firstColon + 1)
const lastColon = rest.lastIndexOf(':')
const maybeData = Number(rest.slice(lastColon + 1))
const hasLegacyData = lastColon >= 0 && Number.isInteger(maybeData)
if (hasLegacyData) {
return {
namespace,
id: rest.slice(0, lastColon),
data: data ?? maybeData,
}
}
return { namespace, id: rest, ...(data !== undefined ? { data } : {}) }
}
export function rawId(ref: ItemRef): string {
return `${ref.namespace}:${ref.id}`
}
export function fullId(ref: ItemRef): string {
return `${rawId(ref)}${ref.data === undefined ? '' : `:${ref.data}`}`
}

View File

@ -0,0 +1,30 @@
// 由 S4 集成
import { invoke } from '@tauri-apps/api/core'
import { createDatapackBlob, type PackFile } from './datapack.ts'
function normalizePackFileName(fileName: string): string {
const segments = fileName.replaceAll('\\', '/').split('/').filter(Boolean)
const safeName = segments[segments.length - 1] ?? fileName
return safeName.toLowerCase().endsWith('.zip') ? safeName : `${safeName}.zip`
}
/**
* Installs a generated datapack into a singleplayer world's `datapacks` directory.
* Returns the installed path relative to the instance root.
*/
export async function exportDatapackToWorld(
instanceId: string,
worldPath: string,
files: PackFile[],
fileName = 'axolotl-recipes.zip',
): Promise<string> {
const blob = createDatapackBlob(files)
const bytes = new Uint8Array(await blob.arrayBuffer())
return await invoke<string>('plugin:instance|instance_install_datapack_to_world_bytes', {
instanceId,
worldPath,
fileName: normalizePackFileName(fileName),
bytes,
})
}

View File

@ -0,0 +1,28 @@
import itemNameIndexData from './assets/vanilla/item-name-index.json'
type RecipeItemNameIndex = {
en_us: Record<string, string>
zh_cn: Record<string, string>
}
const itemNameIndex = itemNameIndexData as RecipeItemNameIndex
function translationKey(id: string): string | null {
const normalized = id.trim().toLowerCase()
const namespaced = normalized.includes(':') ? normalized : `minecraft:${normalized}`
if (!namespaced.startsWith('minecraft:') || /:\d+$/.test(namespaced)) return null
const path = namespaced.slice('minecraft:'.length)
if (!path) return null
const blockKey = `block.minecraft.${path}`
if (blockKey in itemNameIndex.en_us || blockKey in itemNameIndex.zh_cn) return blockKey
const itemKey = `item.minecraft.${path}`
if (itemKey in itemNameIndex.en_us || itemKey in itemNameIndex.zh_cn) return itemKey
return null
}
export function resolveRecipeItemName(id: string, locale: string, readable: string): string {
const preferred = locale.toLowerCase().startsWith('zh') ? 'zh_cn' : 'en_us'
const key = translationKey(id)
if (!key) return readable
return itemNameIndex[preferred][key] ?? itemNameIndex.en_us[key] ?? readable
}

View File

@ -0,0 +1,31 @@
import { defineMessages } from '@modrinth/ui'
export const recipeTypeMessages = defineMessages({
crafting: { id: 'app.lab.recipe-generator.type.crafting', defaultMessage: 'Crafting' },
smelting: { id: 'app.lab.recipe-generator.type.smelting', defaultMessage: 'Smelting' },
blasting: { id: 'app.lab.recipe-generator.type.blasting', defaultMessage: 'Blasting' },
smoking: { id: 'app.lab.recipe-generator.type.smoking', defaultMessage: 'Smoking' },
campfire_cooking: {
id: 'app.lab.recipe-generator.type.campfire-cooking',
defaultMessage: 'Campfire Cooking',
},
stonecutter: { id: 'app.lab.recipe-generator.type.stonecutter', defaultMessage: 'Stonecutter' },
smithing: { id: 'app.lab.recipe-generator.type.smithing', defaultMessage: 'Smithing' },
smithing_trim: {
id: 'app.lab.recipe-generator.type.smithing-trim',
defaultMessage: 'Smithing Trim',
},
smithing_transform: {
id: 'app.lab.recipe-generator.type.smithing-transform',
defaultMessage: 'Smithing Transform',
},
})
export const categoryMessages = defineMessages({
food: { id: 'app.lab.recipe-generator.category.food', defaultMessage: 'Food' },
blocks: { id: 'app.lab.recipe-generator.category.blocks', defaultMessage: 'Blocks' },
misc: { id: 'app.lab.recipe-generator.category.misc', defaultMessage: 'Misc' },
equipment: { id: 'app.lab.recipe-generator.category.equipment', defaultMessage: 'Equipment' },
building: { id: 'app.lab.recipe-generator.category.building', defaultMessage: 'Building' },
redstone: { id: 'app.lab.recipe-generator.category.redstone', defaultMessage: 'Redstone' },
})

View File

@ -0,0 +1,108 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
getAutoRecipeName,
getCurrentRecipeName,
resolveRecipeNames,
sanitizeRecipeName,
} from './naming.ts'
import type { RecipeSlotContext, RecipeState } from './types.ts'
function context(): RecipeSlotContext {
return {
itemsById: {
'minecraft:iron_ingot': { id: 'minecraft:iron_ingot', name: 'Iron Ingot', texture: 'a' },
'minecraft:beef': { id: 'minecraft:beef', name: 'Raw Beef', texture: 'b' },
'minecraft:cooked_beef': { id: 'minecraft:cooked_beef', name: 'Steak', texture: 'c' },
'minecraft:stone': { id: 'minecraft:stone', name: 'Stone', texture: 'd' },
'minecraft:stone_bricks': {
id: 'minecraft:stone_bricks',
name: 'Stone Bricks',
texture: 'e',
},
'minecraft:stick': { id: 'minecraft:stick', name: 'Stick', texture: 'f' },
},
customItemsByUid: {},
customTagsByUid: {},
vanillaTags: {},
}
}
const ctx = context()
function recipe(overrides: Partial<RecipeState> = {}): RecipeState {
return {
id: crypto.randomUUID(),
recipeType: 'crafting',
group: '',
category: '',
showNotification: true,
nameMode: 'auto',
name: '',
slots: {},
crafting: { shapeless: false, keepWhitespace: false, twoByTwo: false },
cooking: { time: null, experience: 0 },
smithing: { trimPattern: '' },
...overrides,
}
}
test('sanitizes recipe file names', () => {
assert.equal(sanitizeRecipeName(' Iron Bars! '), 'iron_bars')
assert.equal(sanitizeRecipeName('a__b---c'), 'a_b_c')
})
test('generates automatic names for every recipe type', () => {
const crafting = recipe({
slots: { 'crafting.result': { kind: 'item', id: 'minecraft:iron_ingot' } },
})
assert.equal(getAutoRecipeName(crafting, ctx), 'iron_ingot')
const smelting = recipe({
recipeType: 'smelting',
slots: {
'cooking.ingredient': { kind: 'item', id: 'minecraft:beef' },
'cooking.result': { kind: 'item', id: 'minecraft:cooked_beef' },
},
})
assert.equal(getAutoRecipeName(smelting, ctx), 'cooked_beef_from_smelting')
const stonecutter = recipe({
recipeType: 'stonecutter',
slots: {
'stonecutter.ingredient': { kind: 'item', id: 'minecraft:stone' },
'stonecutter.result': { kind: 'item', id: 'minecraft:stone_bricks' },
},
})
assert.equal(getAutoRecipeName(stonecutter, ctx), 'stone_bricks_from_stone_stonecutting')
})
test('assigns unique names across duplicate auto names', () => {
const first = recipe({
slots: { 'crafting.result': { kind: 'item', id: 'minecraft:iron_ingot' } },
})
const second = recipe({
slots: { 'crafting.result': { kind: 'item', id: 'minecraft:iron_ingot' } },
})
const names = resolveRecipeNames([first, second], ctx)
assert.equal(names.get(first.id)?.resolvedName, 'iron_ingot')
assert.equal(names.get(second.id)?.resolvedName, 'iron_ingot_2')
})
test('manual names are preserved and empty manual names fall back to auto', () => {
const first = recipe({
nameMode: 'manual',
name: 'my_recipe',
slots: { 'crafting.result': { kind: 'item', id: 'minecraft:iron_ingot' } },
})
const second = recipe({
nameMode: 'manual',
name: '',
slots: { 'crafting.result': { kind: 'item', id: 'minecraft:iron_ingot' } },
})
const names = resolveRecipeNames([first, second], ctx)
assert.equal(names.get(first.id)?.resolvedName, 'my_recipe')
assert.equal(names.get(second.id)?.resolvedName, 'iron_ingot')
assert.equal(getCurrentRecipeName(second, [first, second], ctx).resolvedName, 'iron_ingot')
})

View File

@ -0,0 +1,220 @@
import { parseIdentifier, rawId } from './identifier.ts'
import type { RecipeSlot, RecipeSlotContext, RecipeState, SlotValue } from './types.ts'
const FALLBACK_NAME = 'recipe'
function uniqueNonEmpty(values: Array<string | undefined>): string[] {
return [...new Set(values.filter((value): value is string => Boolean(value)))]
}
export function sanitizeRecipeName(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
}
function itemSlug(value: SlotValue | undefined, ctx: RecipeSlotContext): string | undefined {
const resolved =
value?.kind === 'custom_item'
? ctx.customItemsByUid[value.uid]
: value?.kind === 'custom_tag'
? ctx.customTagsByUid[value.uid]
: value?.kind === 'item' || value?.kind === 'vanilla_tag'
? { id: value.id }
: undefined
if (!resolved) return undefined
const ref = parseIdentifier(resolved.id)
const base = rawId(ref).startsWith('minecraft:')
? rawId(ref).slice('minecraft:'.length)
: rawId(ref).replace(':', '_')
const slug = sanitizeRecipeName(base.replace(/[:/.-]+/g, '_'))
if (!slug) return undefined
return ref.data === undefined || ref.data === 0 ? slug : `${slug}_data_${ref.data}`
}
export function getAutoNameCandidates(recipe: RecipeState, ctx: RecipeSlotContext): string[] {
switch (recipe.recipeType) {
case 'crafting':
return uniqueNonEmpty([itemSlug(recipe.slots['crafting.result'], ctx) ?? 'crafting_recipe'])
case 'smelting':
case 'blasting':
case 'smoking':
case 'campfire_cooking': {
const result = itemSlug(recipe.slots['cooking.result'], ctx)
const ingredient = itemSlug(recipe.slots['cooking.ingredient'], ctx)
const suffix = {
smelting: 'smelting',
blasting: 'blasting',
smoking: 'smoking',
campfire_cooking: 'campfire_cooking',
}[recipe.recipeType]
const names =
recipe.recipeType === 'smelting'
? [
result ? `${result}_from_${suffix}` : undefined,
ingredient ? `${ingredient}_${suffix}` : undefined,
result && ingredient ? `${result}_from_${suffix}_${ingredient}` : undefined,
result,
ensureName(suffix),
]
: [
result ? `${result}_from_${suffix}` : undefined,
ingredient ? `${ingredient}_${suffix}` : undefined,
ensureName(suffix),
]
return uniqueNonEmpty(names)
}
case 'stonecutter': {
const result = itemSlug(recipe.slots['stonecutter.result'], ctx)
const ingredient = itemSlug(recipe.slots['stonecutter.ingredient'], ctx)
let base = 'stonecutting_recipe'
if (result && ingredient) base = `${result}_from_${ingredient}_stonecutting`
else if (result) base = `${result}_stonecutting`
else if (ingredient) base = `${ingredient}_stonecutting`
return uniqueNonEmpty([ensureName(base)])
}
case 'smithing_trim': {
const template = itemSlug(recipe.slots['smithing.template'], ctx)
return uniqueNonEmpty([template ? `${template}_smithing_trim` : 'smithing_trim'])
}
case 'smithing':
case 'smithing_transform': {
const result = itemSlug(recipe.slots['smithing.result'], ctx)
const baseItem = itemSlug(recipe.slots['smithing.base'], ctx)
const base = result
? `${result}_smithing`
: baseItem
? `${baseItem}_smithing`
: 'smithing_recipe'
return uniqueNonEmpty([ensureName(base)])
}
}
}
function ensureName(value: string): string {
return sanitizeRecipeName(value) || FALLBACK_NAME
}
export function getAutoRecipeName(recipe: RecipeState, ctx: RecipeSlotContext): string {
return getAutoNameCandidates(recipe, ctx)[0] ?? FALLBACK_NAME
}
type NameEntry = {
recipe: RecipeState
fixedName?: string
possibleNames: string[]
}
function assignUniqueNames(entries: NameEntry[]): Map<string, string> {
const usedNames = new Set<string>()
const namesById = new Map<string, string>()
for (const entry of entries) {
if (!entry.fixedName) continue
namesById.set(entry.recipe.id, entry.fixedName)
usedNames.add(entry.fixedName)
}
for (const entry of entries) {
if (entry.fixedName) continue
const possibleNames = uniqueNonEmpty(
entry.possibleNames.length ? entry.possibleNames : [FALLBACK_NAME],
)
const existingName = possibleNames.find((name) => !usedNames.has(name))
if (existingName) {
namesById.set(entry.recipe.id, existingName)
usedNames.add(existingName)
continue
}
const base = possibleNames[possibleNames.length - 1] ?? FALLBACK_NAME
let index = 2
let selected = `${base}_${index}`
while (usedNames.has(selected)) {
index += 1
selected = `${base}_${index}`
}
namesById.set(entry.recipe.id, selected)
usedNames.add(selected)
}
return namesById
}
function getSidebarTitle(recipe: RecipeState, ctx: RecipeSlotContext): string | undefined {
const result = recipe.slots[resultSlotForType(recipe.recipeType)]
const resolved =
result?.kind === 'custom_item'
? ctx.customItemsByUid[result.uid]
: result?.kind === 'item'
? { name: ctx.itemsById[result.id]?.name }
: undefined
if (resolved?.name) return resolved.name
return undefined
}
function resultSlotForType(type: RecipeState['recipeType']): RecipeSlot | undefined {
switch (type) {
case 'crafting':
return 'crafting.result'
case 'smelting':
case 'blasting':
case 'smoking':
case 'campfire_cooking':
return 'cooking.result'
case 'stonecutter':
return 'stonecutter.result'
case 'smithing':
case 'smithing_transform':
return 'smithing.result'
case 'smithing_trim':
return undefined
}
}
export type ResolvedRecipeNaming = {
autoName: string
resolvedName: string
sidebarTitle: string
}
export function resolveRecipeNames(
recipes: RecipeState[],
ctx: RecipeSlotContext,
): Map<string, ResolvedRecipeNaming> {
const entries: NameEntry[] = recipes.map((recipe) => {
const manualName = recipe.nameMode === 'manual' ? sanitizeRecipeName(recipe.name) : undefined
return {
recipe,
fixedName: manualName || undefined,
possibleNames: getAutoNameCandidates(recipe, ctx),
}
})
const assigned = assignUniqueNames(entries)
const result = new Map<string, ResolvedRecipeNaming>()
for (const entry of entries) {
result.set(entry.recipe.id, {
autoName: getAutoRecipeName(entry.recipe, ctx),
resolvedName: assigned.get(entry.recipe.id) ?? getAutoRecipeName(entry.recipe, ctx),
sidebarTitle: getSidebarTitle(entry.recipe, ctx) ?? '',
})
}
return result
}
export function getCurrentRecipeName(
recipe: RecipeState,
recipes: RecipeState[],
ctx: RecipeSlotContext,
): ResolvedRecipeNaming {
const resolved = resolveRecipeNames(recipes, ctx).get(recipe.id)
return (
resolved ?? {
autoName: getAutoRecipeName(recipe, ctx),
resolvedName: getAutoRecipeName(recipe, ctx),
sidebarTitle: getSidebarTitle(recipe, ctx) ?? '',
}
)
}

View File

@ -0,0 +1,195 @@
import { getSlotDisplay } from './display.ts'
import { getCraftingGridValues } from './recipe-engine.ts'
import type { TextureAtlas } from './resources.ts'
import type { RecipeSlotContext, RecipeState } from './types.ts'
const imageCache = new Map<string, Promise<HTMLImageElement>>()
function loadImage(url: string): Promise<HTMLImageElement> {
const cached = imageCache.get(url)
if (cached) return cached
const promise = new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => reject(new Error(`Unable to load texture: ${url}`))
image.src = url
})
imageCache.set(url, promise)
return promise
}
async function drawSlot(
canvas: HTMLCanvasElement,
x: number,
y: number,
value: ReturnType<typeof getSlotDisplay>,
atlas: TextureAtlas,
image: HTMLImageElement,
): Promise<void> {
const context = canvas.getContext('2d')
if (!context) return
const size = 32
context.fillStyle = 'rgba(139,139,139,1)'
context.fillRect(x, y, size, size)
context.strokeStyle = 'rgba(55,55,55,1)'
context.lineWidth = 1
context.strokeRect(x + 0.5, y + 0.5, size - 1, size - 1)
if (value?.texture && atlas.layout[value.texture]) {
const [ux, uy, uw, uh] = atlas.layout[value.texture]
context.drawImage(image, ux, uy, uw, uh, x + 1, y + 1, 30, 30)
} else if (value?.texture) {
try {
const customImage = await loadImage(value.texture)
context.drawImage(customImage, x + 1, y + 1, 30, 30)
} catch {
// Custom textures may be unreachable; keep the empty slot.
}
}
if (value?.count && value.count > 1) {
context.fillStyle = 'rgba(255,255,255,1)'
context.font = 'bold 12px sans-serif'
context.textAlign = 'right'
context.textBaseline = 'bottom'
context.shadowColor = 'rgba(0,0,0,1)'
context.shadowOffsetX = 1
context.shadowOffsetY = 1
context.fillText(String(value.count), x + size - 2, y + size - 2)
context.shadowColor = 'transparent'
}
}
function canvasSize(recipe: RecipeState): { width: number; height: number } {
if (recipe.recipeType === 'crafting') {
return { width: 220, height: 130 }
}
if (
recipe.recipeType === 'smelting' ||
recipe.recipeType === 'blasting' ||
recipe.recipeType === 'smoking' ||
recipe.recipeType === 'campfire_cooking'
) {
return { width: 200, height: 90 }
}
if (recipe.recipeType === 'stonecutter') {
return { width: 220, height: 110 }
}
return { width: 300, height: 120 }
}
async function drawPreview(
canvas: HTMLCanvasElement,
recipe: RecipeState,
ctx: RecipeSlotContext,
atlas: TextureAtlas,
image: HTMLImageElement,
): Promise<void> {
const context = canvas.getContext('2d')
if (!context) return
context.clearRect(0, 0, canvas.width, canvas.height)
context.fillStyle = 'rgba(198,198,198,1)'
context.fillRect(0, 0, canvas.width, canvas.height)
if (recipe.recipeType === 'crafting') {
const grid = getCraftingGridValues(recipe)
for (const [index, value] of grid.entries()) {
const x = 10 + (index % 3) * 40
const y = 14 + Math.floor(index / 3) * 40
await drawSlot(canvas, x, y, getSlotDisplay(value, ctx), atlas, image)
}
await drawSlot(
canvas,
150,
54,
getSlotDisplay(recipe.slots['crafting.result'], ctx),
atlas,
image,
)
return
}
if (
recipe.recipeType === 'smelting' ||
recipe.recipeType === 'blasting' ||
recipe.recipeType === 'smoking' ||
recipe.recipeType === 'campfire_cooking'
) {
await drawSlot(
canvas,
20,
30,
getSlotDisplay(recipe.slots['cooking.ingredient'], ctx),
atlas,
image,
)
await drawSlot(
canvas,
140,
30,
getSlotDisplay(recipe.slots['cooking.result'], ctx),
atlas,
image,
)
return
}
if (recipe.recipeType === 'stonecutter') {
await drawSlot(
canvas,
20,
40,
getSlotDisplay(recipe.slots['stonecutter.ingredient'], ctx),
atlas,
image,
)
await drawSlot(
canvas,
160,
40,
getSlotDisplay(recipe.slots['stonecutter.result'], ctx),
atlas,
image,
)
return
}
const smithingSlots = [
['smithing.template', 10] as const,
['smithing.base', 62] as const,
['smithing.addition', 114] as const,
['smithing.result', 240] as const,
]
for (const [slot, x] of smithingSlots) {
await drawSlot(canvas, x, 44, getSlotDisplay(recipe.slots[slot], ctx), atlas, image)
}
}
export async function createRecipePreviewPngBlob(
recipe: RecipeState,
ctx: RecipeSlotContext,
atlas: TextureAtlas,
): Promise<Blob> {
const image = await loadImage(atlas.url)
const { width, height } = canvasSize(recipe)
const canvas = document.createElement('canvas')
canvas.width = width * 2
canvas.height = height * 2
const context = canvas.getContext('2d')
if (!context) throw new Error('Unable to create a canvas context')
context.scale(2, 2)
await drawPreview(canvas, recipe, ctx, atlas, image)
return await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((result) => {
if (result) resolve(result)
else reject(new Error('Unable to encode preview PNG'))
}, 'image/png')
})
}
export async function copyRecipePreviewToClipboard(
recipe: RecipeState,
ctx: RecipeSlotContext,
atlas: TextureAtlas,
): Promise<void> {
const blob = await createRecipePreviewPngBlob(recipe, ctx, atlas)
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
}

View File

@ -0,0 +1,220 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { generateJavaRecipe } from './recipe-engine.ts'
import type { RecipeSlotContext, RecipeState } from './types.ts'
function context(): RecipeSlotContext {
return {
itemsById: {
'minecraft:iron_ingot': {
id: 'minecraft:iron_ingot',
name: 'Iron Ingot',
texture: 'iron.png',
},
'minecraft:stick': { id: 'minecraft:stick', name: 'Stick', texture: 'stick.png' },
'minecraft:stone': { id: 'minecraft:stone', name: 'Stone', texture: 'stone.png' },
'minecraft:stone_bricks': {
id: 'minecraft:stone_bricks',
name: 'Stone Bricks',
texture: 'bricks.png',
},
'minecraft:diamond': { id: 'minecraft:diamond', name: 'Diamond', texture: 'diamond.png' },
'minecraft:diamond_sword': {
id: 'minecraft:diamond_sword',
name: 'Diamond Sword',
texture: 'sword.png',
},
'minecraft:iron_bars': {
id: 'minecraft:iron_bars',
name: 'Iron Bars',
texture: 'bars.png',
},
'minecraft:oak_planks': {
id: 'minecraft:oak_planks',
name: 'Oak Planks',
texture: 'planks.png',
},
'minecraft:beef': { id: 'minecraft:beef', name: 'Raw Beef', texture: 'beef.png' },
'minecraft:cooked_beef': {
id: 'minecraft:cooked_beef',
name: 'Steak',
texture: 'steak.png',
},
},
customItemsByUid: {},
customTagsByUid: {},
vanillaTags: {
'minecraft:planks': ['minecraft:oak_planks'],
},
}
}
function recipe(overrides: Partial<RecipeState> = {}): RecipeState {
return {
id: 'recipe-1',
recipeType: 'crafting',
group: '',
category: '',
showNotification: true,
nameMode: 'auto',
name: '',
slots: {},
crafting: { shapeless: false, keepWhitespace: false, twoByTwo: false },
cooking: { time: null, experience: 0 },
smithing: { trimPattern: '' },
...overrides,
}
}
const ctx = context()
test('builds shaped crafting patterns and keys', () => {
const state = recipe({
slots: {
'crafting.1': { kind: 'item', id: 'minecraft:iron_ingot' },
'crafting.2': { kind: 'item', id: 'minecraft:iron_ingot' },
'crafting.3': { kind: 'item', id: 'minecraft:iron_ingot' },
'crafting.4': { kind: 'item', id: 'minecraft:stick' },
'crafting.result': { kind: 'item', id: 'minecraft:iron_bars', count: 16 },
},
})
const output = generateJavaRecipe(state, '1.21.2', ctx)
assert.deepEqual(output.pattern, ['###', '/ '])
assert.deepEqual(output.key, {
'#': 'minecraft:iron_ingot',
'/': 'minecraft:stick',
})
assert.deepEqual(output.result, { id: 'minecraft:iron_bars', count: 16 })
assert.equal(output.type, 'minecraft:crafting_shaped')
})
test('builds shapeless crafting with tag ingredients', () => {
const state = recipe({
crafting: { shapeless: true, keepWhitespace: false, twoByTwo: false },
slots: {
'crafting.1': { kind: 'vanilla_tag', id: 'minecraft:planks' },
'crafting.2': { kind: 'vanilla_tag', id: 'minecraft:planks' },
'crafting.result': { kind: 'item', id: 'minecraft:stick' },
},
})
const output = generateJavaRecipe(state, '1.21.2', ctx)
assert.deepEqual(output.ingredients, ['#minecraft:planks', '#minecraft:planks'])
assert.equal(output.type, 'minecraft:crafting_shapeless')
})
test('two by two crafting only keeps the 2x2 grid', () => {
const state = recipe({
crafting: { shapeless: false, keepWhitespace: false, twoByTwo: true },
slots: {
'crafting.1': { kind: 'item', id: 'minecraft:oak_planks' },
'crafting.2': { kind: 'item', id: 'minecraft:oak_planks' },
'crafting.4': { kind: 'item', id: 'minecraft:oak_planks' },
'crafting.5': { kind: 'item', id: 'minecraft:oak_planks' },
'crafting.result': { kind: 'item', id: 'minecraft:oak_planks', count: 4 },
},
})
const output = generateJavaRecipe(state, '1.21', ctx)
assert.deepEqual(output.pattern, ['##', '##'])
})
test('formats smelting across version boundaries', () => {
const state = recipe({
recipeType: 'smelting',
slots: {
'cooking.ingredient': { kind: 'item', id: 'minecraft:beef' },
'cooking.result': { kind: 'item', id: 'minecraft:cooked_beef' },
},
cooking: { time: null, experience: 0.35 },
})
const legacy = generateJavaRecipe(state, '1.14', ctx)
assert.equal(legacy.type, 'minecraft:smelting')
assert.equal(legacy.cookingtime, 200)
assert.equal(legacy.result, 'minecraft:cooked_beef')
const modern = generateJavaRecipe(state, '1.20', ctx)
assert.deepEqual(modern.result, { id: 'minecraft:cooked_beef' })
})
test('formats stonecutting result and count', () => {
const state = recipe({
recipeType: 'stonecutter',
slots: {
'stonecutter.ingredient': { kind: 'item', id: 'minecraft:stone' },
'stonecutter.result': { kind: 'item', id: 'minecraft:stone_bricks', count: 2 },
},
})
const legacy = generateJavaRecipe(state, '1.14', ctx)
assert.equal(legacy.result, 'minecraft:stone_bricks')
assert.equal(legacy.count, 2)
const modern = generateJavaRecipe(state, '1.20', ctx)
assert.deepEqual(modern.result, { id: 'minecraft:stone_bricks', count: 2 })
})
test('formats legacy smithing, trim, and transform recipes', () => {
const smithing = recipe({
recipeType: 'smithing',
slots: {
'smithing.base': { kind: 'item', id: 'minecraft:iron_bars' },
'smithing.addition': { kind: 'item', id: 'minecraft:diamond' },
'smithing.result': { kind: 'item', id: 'minecraft:diamond_sword' },
},
})
const legacy = generateJavaRecipe(smithing, '1.16', ctx)
assert.equal(legacy.type, 'minecraft:smithing')
assert.deepEqual(legacy.base, { item: 'minecraft:iron_bars' })
assert.deepEqual(legacy.result, { item: 'minecraft:diamond_sword' })
const trim = recipe({
recipeType: 'smithing_trim',
smithing: { trimPattern: 'minecraft:coast' },
slots: {
'smithing.template': { kind: 'item', id: 'minecraft:iron_bars' },
'smithing.base': { kind: 'item', id: 'minecraft:diamond_sword' },
'smithing.addition': { kind: 'item', id: 'minecraft:diamond' },
},
})
const trimOutput = generateJavaRecipe(trim, '1.21.5', ctx)
assert.equal(trimOutput.type, 'minecraft:smithing_trim')
assert.equal(trimOutput.pattern, 'minecraft:coast')
const transform = recipe({
recipeType: 'smithing_transform',
slots: {
'smithing.template': { kind: 'item', id: 'minecraft:iron_bars' },
'smithing.base': { kind: 'item', id: 'minecraft:diamond_sword' },
'smithing.addition': { kind: 'item', id: 'minecraft:diamond' },
'smithing.result': { kind: 'item', id: 'minecraft:diamond_sword' },
},
})
const transformOutput = generateJavaRecipe(transform, '1.19', ctx)
assert.equal(transformOutput.type, 'minecraft:smithing_transform')
assert.deepEqual(transformOutput.template, { item: 'minecraft:iron_bars' })
})
test('gates category, show_notification, and stonecutter group by version', () => {
const shaped = recipe({
category: 'building',
showNotification: false,
slots: {
'crafting.1': { kind: 'item', id: 'minecraft:oak_planks' },
'crafting.result': { kind: 'item', id: 'minecraft:stick' },
},
})
assert.equal(generateJavaRecipe(shaped, '1.18', ctx).category, undefined)
assert.equal(generateJavaRecipe(shaped, '1.18', ctx).show_notification, undefined)
assert.equal(generateJavaRecipe(shaped, '1.19', ctx).category, 'building')
assert.equal(generateJavaRecipe(shaped, '1.19', ctx).show_notification, false)
const stonecutter = recipe({
recipeType: 'stonecutter',
group: 'stone',
slots: {
'stonecutter.ingredient': { kind: 'item', id: 'minecraft:stone' },
'stonecutter.result': { kind: 'item', id: 'minecraft:stone_bricks' },
},
})
assert.equal(generateJavaRecipe(stonecutter, '1.20', ctx).group, 'stone')
assert.equal(generateJavaRecipe(stonecutter, '26.1', ctx).group, undefined)
})

View File

@ -0,0 +1,392 @@
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)
}

View File

@ -0,0 +1,136 @@
import stonecutterImage from '@/components/lab/recipe-generator/bg/切石机.png?url'
import craftingImage from '@/components/lab/recipe-generator/bg/合成.png?url'
import smeltingImage from '@/components/lab/recipe-generator/bg/熔炼.png?url'
import campfireImage from '@/components/lab/recipe-generator/bg/篝火.png?url'
import smithingImage from '@/components/lab/recipe-generator/bg/锻造.png?url'
import type { RecipeSlot, RecipeType } from './types.ts'
export const RECIPE_IMAGE_WIDTH = 696
export const RECIPE_IMAGE_HEIGHT = 292
export type RecipeLayoutSlotBox = {
x1: number
y1: number
x2: number
y2: number
}
export type RecipeLayout = {
recipeType: RecipeType
image: string
imageWidth: number
imageHeight: number
slots: Partial<Record<RecipeSlot, RecipeLayoutSlotBox>>
}
export const RECIPE_LAYOUTS: readonly RecipeLayout[] = [
{
recipeType: 'campfire_cooking',
image: campfireImage,
imageWidth: RECIPE_IMAGE_WIDTH,
imageHeight: RECIPE_IMAGE_HEIGHT,
slots: {
'cooking.ingredient': { x1: 142, y1: 132, x2: 203, y2: 195 },
'cooking.result': { x1: 512, y1: 132, x2: 575, y2: 195 },
},
},
{
recipeType: 'crafting',
image: craftingImage,
imageWidth: RECIPE_IMAGE_WIDTH,
imageHeight: RECIPE_IMAGE_HEIGHT,
slots: {
'crafting.1': { x1: 117, y1: 64, x2: 181, y2: 128 },
'crafting.2': { x1: 188, y1: 64, x2: 252, y2: 128 },
'crafting.3': { x1: 259, y1: 64, x2: 323, y2: 128 },
'crafting.4': { x1: 117, y1: 135, x2: 181, y2: 199 },
'crafting.5': { x1: 188, y1: 135, x2: 252, y2: 199 },
'crafting.6': { x1: 259, y1: 135, x2: 323, y2: 199 },
'crafting.7': { x1: 117, y1: 206, x2: 181, y2: 270 },
'crafting.8': { x1: 188, y1: 206, x2: 252, y2: 270 },
'crafting.9': { x1: 259, y1: 206, x2: 323, y2: 270 },
'crafting.result': { x1: 476, y1: 121, x2: 571, y2: 215 },
},
},
{
recipeType: 'stonecutter',
image: stonecutterImage,
imageWidth: RECIPE_IMAGE_WIDTH,
imageHeight: RECIPE_IMAGE_HEIGHT,
slots: {
'stonecutter.ingredient': { x1: 76, y1: 129, x2: 139, y2: 191 },
'stonecutter.result': { x1: 552, y1: 112, x2: 647, y2: 207 },
},
},
{
recipeType: 'smelting',
image: smeltingImage,
imageWidth: RECIPE_IMAGE_WIDTH,
imageHeight: RECIPE_IMAGE_HEIGHT,
slots: {
'cooking.ingredient': { x1: 220, y1: 64, x2: 283, y2: 127 },
'cooking.result': { x1: 444, y1: 120, x2: 539, y2: 215 },
},
},
{
recipeType: 'blasting',
image: smeltingImage,
imageWidth: RECIPE_IMAGE_WIDTH,
imageHeight: RECIPE_IMAGE_HEIGHT,
slots: {
'cooking.ingredient': { x1: 220, y1: 64, x2: 283, y2: 127 },
'cooking.result': { x1: 444, y1: 120, x2: 539, y2: 215 },
},
},
{
recipeType: 'smoking',
image: smeltingImage,
imageWidth: RECIPE_IMAGE_WIDTH,
imageHeight: RECIPE_IMAGE_HEIGHT,
slots: {
'cooking.ingredient': { x1: 220, y1: 64, x2: 283, y2: 127 },
'cooking.result': { x1: 444, y1: 120, x2: 539, y2: 215 },
},
},
{
recipeType: 'smithing',
image: smithingImage,
imageWidth: RECIPE_IMAGE_WIDTH,
imageHeight: RECIPE_IMAGE_HEIGHT,
slots: {
'smithing.template': { x1: 28, y1: 188, x2: 92, y2: 252 },
'smithing.base': { x1: 99, y1: 188, x2: 163, y2: 252 },
'smithing.addition': { x1: 170, y1: 188, x2: 234, y2: 252 },
'smithing.result': { x1: 388, y1: 188, x2: 451, y2: 251 },
},
},
{
recipeType: 'smithing_trim',
image: smithingImage,
imageWidth: RECIPE_IMAGE_WIDTH,
imageHeight: RECIPE_IMAGE_HEIGHT,
slots: {
'smithing.template': { x1: 28, y1: 188, x2: 92, y2: 252 },
'smithing.base': { x1: 99, y1: 188, x2: 163, y2: 252 },
'smithing.addition': { x1: 170, y1: 188, x2: 234, y2: 252 },
'smithing.result': { x1: 388, y1: 188, x2: 451, y2: 251 },
},
},
{
recipeType: 'smithing_transform',
image: smithingImage,
imageWidth: RECIPE_IMAGE_WIDTH,
imageHeight: RECIPE_IMAGE_HEIGHT,
slots: {
'smithing.template': { x1: 28, y1: 188, x2: 92, y2: 252 },
'smithing.base': { x1: 99, y1: 188, x2: 163, y2: 252 },
'smithing.addition': { x1: 170, y1: 188, x2: 234, y2: 252 },
'smithing.result': { x1: 388, y1: 188, x2: 451, y2: 251 },
},
},
]
export function getRecipeLayout(recipeType: RecipeType): RecipeLayout | null {
return RECIPE_LAYOUTS.find((layout) => layout.recipeType === recipeType) ?? null
}

View File

@ -0,0 +1,112 @@
import atlasLayoutData from './assets/texture-atlas.json'
import atlasUrl from './assets/texture-atlas.png?url'
import type { CustomItem, CustomTag, JavaVersionId, RecipeSlotContext } from './types.ts'
export type TextureAtlas = {
url: string
layout: Record<string, [number, number, number, number]>
}
export const TEXTURE_ATLAS: TextureAtlas = {
url: atlasUrl,
layout: atlasLayoutData as Record<string, [number, number, number, number]>,
}
export const TEXTURE_ATLAS_SIZE = {
width: 2_048,
height: 1_312,
} as const
export type TextureManifestItem = {
id: string
readable: string
texture: string
}
export type TextureManifest = {
version: string
items: TextureManifestItem[]
}
export type ItemInfo = {
id: string
name: string
texture: string | null
}
export type LoadedVersionResources = {
version: JavaVersionId
items: ItemInfo[]
itemsById: Record<string, ItemInfo>
vanillaTags: Record<string, string[]>
}
const itemLoaders = import.meta.glob<{ default: TextureManifest }>('./assets/items/*.json')
const tagLoaders = import.meta.glob<{ default: Record<string, string[]> }>('./assets/tags/*.json')
const versionResourcesCache = new Map<JavaVersionId, Promise<LoadedVersionResources>>()
const itemCache = new Map<string, Promise<ItemInfo[]>>()
const tagCache = new Map<string, Promise<Record<string, string[]>>>()
export async function loadVersionResources(
version: JavaVersionId,
): Promise<LoadedVersionResources> {
const cached = versionResourcesCache.get(version)
if (cached) return cached
const promise = (async () => {
const [items, vanillaTags] = await Promise.all([loadItems(version), loadVanillaTags(version)])
const itemsById: Record<string, ItemInfo> = {}
for (const item of items) itemsById[item.id] = item
return { version, items, itemsById, vanillaTags }
})()
versionResourcesCache.set(version, promise)
return promise
}
export async function loadItems(version: JavaVersionId): Promise<ItemInfo[]> {
const cached = itemCache.get(version)
if (cached) return cached
const loader = itemLoaders[`./assets/items/${version}.json`]
if (!loader) throw new Error(`No item manifest for ${version}`)
const promise = loader().then(({ default: manifest }) =>
(manifest.items ?? []).map((item) => ({
id: item.id,
name: item.readable,
texture: item.texture || null,
})),
)
itemCache.set(version, promise)
return promise
}
export async function loadVanillaTags(version: JavaVersionId): Promise<Record<string, string[]>> {
const cached = tagCache.get(version)
if (cached) return cached
const loader = tagLoaders[`./assets/tags/${version}.json`]
const promise = loader ? loader().then(({ default: tags }) => tags) : Promise.resolve({})
tagCache.set(version, promise)
return promise
}
export function buildSlotContext(
customItems: CustomItem[],
customTags: CustomTag[],
resources: LoadedVersionResources,
): RecipeSlotContext {
const customItemsByUid: Record<string, CustomItem> = {}
for (const item of customItems) customItemsByUid[item.uid] = item
const customTagsByUid: Record<string, CustomTag> = {}
for (const tag of customTags) customTagsByUid[tag.uid] = tag
return {
itemsById: resources.itemsById,
customItemsByUid,
customTagsByUid,
vanillaTags: resources.vanillaTags,
}
}
export function clearResourceCaches(): void {
versionResourcesCache.clear()
itemCache.clear()
tagCache.clear()
}

View File

@ -0,0 +1,23 @@
{
"EditedAt": "2026-08-08T00:07:53+08:00",
"sources": [
{
"id": "vanilla-tags",
"repository": "https://github.com/destruc7i0n/crafting",
"commit": "e6c71dd816216a73cda2787aa5253f641b57fbeb",
"license": "MIT",
"copyright": "Copyright (c) 2017 TheDestruc7i0n",
"licenseFile": "third-party/licenses/MIT.txt",
"note": "Expanded item tags."
},
{
"id": "minecraft-textures",
"repository": "https://github.com/destruc7i0n/minecraft-textures",
"npmVersion": "26.2.1",
"license": "GPL-3.0",
"author": "TheDestruc7i0n",
"licenseFile": "apps/app-frontend/LICENSE",
"note": "Item identifiers, readable names, and the texture atlas."
}
]
}

View File

@ -0,0 +1,117 @@
export type JavaVersionId =
| '1.12'
| '1.13'
| '1.14'
| '1.15'
| '1.16'
| '1.17'
| '1.18'
| '1.19'
| '1.20'
| '1.21'
| '1.21.2'
| '1.21.4'
| '1.21.5'
| '1.21.6'
| '1.21.7'
| '1.21.9'
| '1.21.11'
| '26.1'
| '26.2'
export type RecipeType =
| 'crafting'
| 'smelting'
| 'blasting'
| 'smoking'
| 'campfire_cooking'
| 'stonecutter'
| 'smithing'
| 'smithing_trim'
| 'smithing_transform'
export type PackFormatVersion = number | [number, number]
export type RecipeSlot =
| 'crafting.1'
| 'crafting.2'
| 'crafting.3'
| 'crafting.4'
| 'crafting.5'
| 'crafting.6'
| 'crafting.7'
| 'crafting.8'
| 'crafting.9'
| 'crafting.result'
| 'cooking.ingredient'
| 'cooking.result'
| 'stonecutter.ingredient'
| 'stonecutter.result'
| 'smithing.template'
| 'smithing.base'
| 'smithing.addition'
| 'smithing.result'
export type SlotValue =
| { kind: 'item'; id: string; count?: number }
| { kind: 'custom_item'; uid: string; count?: number }
| { kind: 'vanilla_tag'; id: string }
| { kind: 'custom_tag'; uid: string }
export type CustomItem = {
uid: string
id: string
name: string
texture: string
createdAt: string
}
export type TagValue = {
type: 'item' | 'tag'
id: string
}
export type CustomTag = {
uid: string
id: string
values: TagValue[]
}
export type RecipeState = {
id: string
recipeType: RecipeType
group: string
category: string
showNotification: boolean
nameMode: 'auto' | 'manual'
name: string
slots: Partial<Record<RecipeSlot, SlotValue>>
crafting: {
shapeless: boolean
keepWhitespace: boolean
twoByTwo: boolean
}
cooking: {
time: number | null
experience: number
}
smithing: {
trimPattern: string
}
}
export type RecipeSlotContext = {
itemsById: Record<string, { id: string; name: string; texture: string | null }>
customItemsByUid: Record<string, CustomItem>
customTagsByUid: Record<string, CustomTag>
vanillaTags: Record<string, string[]>
}
export type RecipeGeneratorStore = {
version: 1
selectedVersion: JavaVersionId
recipes: RecipeState[]
selectedRecipeId: string
customItems: CustomItem[]
customTags: CustomTag[]
}

View File

@ -0,0 +1,153 @@
import { parseIdentifier } from './identifier.ts'
import type {
JavaVersionId,
RecipeSlot,
RecipeSlotContext,
RecipeState,
SlotValue,
} from './types.ts'
import {
isRecipeTypeAvailable,
RESULT_SLOTS_BY_TYPE,
supportsItemTags,
supportsSmithingTrimPattern,
} from './versions.ts'
export type RecipeIssueCode =
| 'unsupported-type'
| 'missing-ingredient'
| 'missing-result'
| 'missing-template'
| 'missing-base'
| 'missing-addition'
| 'missing-trim-pattern'
| 'tag-in-result'
| 'missing-custom-item'
| 'missing-custom-tag'
| 'invalid-identifier'
| 'tags-not-supported'
export type RecipeIssue = {
code: RecipeIssueCode
slot?: RecipeSlot
}
const RESULT_SLOTS = new Set<RecipeSlot>([
'crafting.result',
'cooking.result',
'stonecutter.result',
'smithing.result',
])
function isTagValue(value: SlotValue): boolean {
return value.kind === 'vanilla_tag' || value.kind === 'custom_tag'
}
function isValidIdentifierPart(value: string, allowSlash: boolean): boolean {
const trimmed = value.trim()
if (!trimmed || /\s/.test(trimmed)) return false
return new RegExp(`^[a-z0-9_.\\-${allowSlash ? '/' : ''}]+$`).test(trimmed)
}
function hasInvalidIdentifier(value: SlotValue | undefined, ctx: RecipeSlotContext): boolean {
if (!value) return false
const resolved =
value.kind === 'custom_item'
? ctx.customItemsByUid[value.uid]
: value.kind === 'custom_tag'
? ctx.customTagsByUid[value.uid]
: value.kind === 'item' || value.kind === 'vanilla_tag'
? { id: value.id }
: undefined
if (!resolved) return false
const ref = parseIdentifier(resolved.id)
return !isValidIdentifierPart(ref.namespace, false) || !isValidIdentifierPart(ref.id, true)
}
export function validateRecipe(
state: RecipeState,
version: JavaVersionId,
ctx: RecipeSlotContext,
): RecipeIssue[] {
const issues: RecipeIssue[] = []
if (!isRecipeTypeAvailable(version, state.recipeType)) {
issues.push({ code: 'unsupported-type' })
}
const hasTag = Object.values(state.slots).some((value) => value && isTagValue(value))
if (hasTag && !supportsItemTags(version)) {
issues.push({ code: 'tags-not-supported' })
}
for (const [slot, value] of Object.entries(state.slots) as [RecipeSlot, SlotValue][]) {
if (!value) continue
if (value.kind === 'custom_item' && !ctx.customItemsByUid[value.uid]) {
issues.push({ code: 'missing-custom-item', slot })
}
if (value.kind === 'custom_tag' && !ctx.customTagsByUid[value.uid]) {
issues.push({ code: 'missing-custom-tag', slot })
}
if (isTagValue(value) && RESULT_SLOTS.has(slot)) {
issues.push({ code: 'tag-in-result', slot })
}
if (hasInvalidIdentifier(value, ctx)) {
issues.push({ code: 'invalid-identifier', slot })
}
}
const typeIssues = validateTypeRules(state, version)
return [...issues, ...typeIssues]
}
function validateTypeRules(state: RecipeState, version: JavaVersionId): RecipeIssue[] {
const issues: RecipeIssue[] = []
switch (state.recipeType) {
case 'crafting': {
const hasIngredient = Object.keys(state.slots).some(
(slot) =>
slot.startsWith('crafting.') &&
slot !== 'crafting.result' &&
state.slots[slot as RecipeSlot],
)
if (!hasIngredient) issues.push({ code: 'missing-ingredient' })
if (!state.slots['crafting.result']) issues.push({ code: 'missing-result' })
break
}
case 'smelting':
case 'blasting':
case 'smoking':
case 'campfire_cooking':
if (!state.slots['cooking.ingredient']) issues.push({ code: 'missing-ingredient' })
if (!state.slots['cooking.result']) issues.push({ code: 'missing-result' })
break
case 'stonecutter':
if (!state.slots['stonecutter.ingredient']) issues.push({ code: 'missing-ingredient' })
if (!state.slots['stonecutter.result']) issues.push({ code: 'missing-result' })
break
case 'smithing':
if (!state.slots['smithing.base']) issues.push({ code: 'missing-base' })
if (!state.slots['smithing.addition']) issues.push({ code: 'missing-addition' })
if (!state.slots['smithing.result']) issues.push({ code: 'missing-result' })
break
case 'smithing_trim':
if (!state.slots['smithing.template']) issues.push({ code: 'missing-template' })
if (!state.slots['smithing.base']) issues.push({ code: 'missing-base' })
if (!state.slots['smithing.addition']) issues.push({ code: 'missing-addition' })
if (supportsSmithingTrimPattern(version) && !state.smithing.trimPattern.trim()) {
issues.push({ code: 'missing-trim-pattern' })
}
break
case 'smithing_transform':
if (!state.slots['smithing.template']) issues.push({ code: 'missing-template' })
if (!state.slots['smithing.base']) issues.push({ code: 'missing-base' })
if (!state.slots['smithing.addition']) issues.push({ code: 'missing-addition' })
if (!state.slots['smithing.result']) issues.push({ code: 'missing-result' })
break
}
return issues
}
export function resultSlotsForType(type: RecipeState['recipeType']): readonly RecipeSlot[] {
return RESULT_SLOTS_BY_TYPE[type] ?? []
}

View File

@ -0,0 +1,261 @@
import type { JavaVersionId, PackFormatVersion, RecipeSlot, RecipeType } from './types.ts'
export type JavaVersionMeta = {
id: JavaVersionId
packFormat: PackFormatVersion | null
recipeDir: 'recipe' | 'recipes' | null
tagDir: 'tags/item' | 'tags/items' | null
hasVanillaTags: boolean
}
const metadata: Record<JavaVersionId, Omit<JavaVersionMeta, 'id' | 'hasVanillaTags'>> = {
'1.12': { packFormat: null, recipeDir: null, tagDir: null },
'1.13': { packFormat: 4, recipeDir: 'recipes', tagDir: 'tags/items' },
'1.14': { packFormat: 4, recipeDir: 'recipes', tagDir: 'tags/items' },
'1.15': { packFormat: 5, recipeDir: 'recipes', tagDir: 'tags/items' },
'1.16': { packFormat: 6, recipeDir: 'recipes', tagDir: 'tags/items' },
'1.17': { packFormat: 7, recipeDir: 'recipes', tagDir: 'tags/items' },
'1.18': { packFormat: 9, recipeDir: 'recipes', tagDir: 'tags/items' },
'1.19': { packFormat: 12, recipeDir: 'recipes', tagDir: 'tags/items' },
'1.20': { packFormat: 41, recipeDir: 'recipes', tagDir: 'tags/items' },
'1.21': { packFormat: 48, recipeDir: 'recipe', tagDir: 'tags/item' },
'1.21.2': { packFormat: 57, recipeDir: 'recipe', tagDir: 'tags/item' },
'1.21.4': { packFormat: 61, recipeDir: 'recipe', tagDir: 'tags/item' },
'1.21.5': { packFormat: 71, recipeDir: 'recipe', tagDir: 'tags/item' },
'1.21.6': { packFormat: 80, recipeDir: 'recipe', tagDir: 'tags/item' },
'1.21.7': { packFormat: 81, recipeDir: 'recipe', tagDir: 'tags/item' },
'1.21.9': { packFormat: [88, 0], recipeDir: 'recipe', tagDir: 'tags/item' },
'1.21.11': { packFormat: [94, 1], recipeDir: 'recipe', tagDir: 'tags/item' },
'26.1': { packFormat: [101, 1], recipeDir: 'recipe', tagDir: 'tags/item' },
'26.2': { packFormat: [107, 1], recipeDir: 'recipe', tagDir: 'tags/item' },
}
const tagVersions = new Set([
'1.14',
'1.15',
'1.16',
'1.17',
'1.18',
'1.19',
'1.20',
'1.21',
'1.21.2',
'1.21.4',
'1.21.5',
'1.21.6',
'1.21.7',
'1.21.9',
'1.21.11',
'26.1',
'26.2',
])
export const JAVA_VERSIONS: readonly JavaVersionMeta[] = (
[
'26.2',
'26.1',
'1.21.11',
'1.21.9',
'1.21.7',
'1.21.6',
'1.21.5',
'1.21.4',
'1.21.2',
'1.21',
'1.20',
'1.19',
'1.18',
'1.17',
'1.16',
'1.15',
'1.14',
'1.13',
'1.12',
] as const
).map((id) => ({
id,
...metadata[id],
hasVanillaTags: tagVersions.has(id),
}))
export const LATEST_JAVA_VERSION: JavaVersionId = '26.2'
const byId = new Map<JavaVersionId, JavaVersionMeta>(
JAVA_VERSIONS.map((version) => [version.id, version]),
)
export function getJavaVersionMeta(version: JavaVersionId): JavaVersionMeta {
const meta = byId.get(version)
if (!meta) throw new Error(`Unknown Java version: ${version}`)
return meta
}
export function isJavaVersionId(value: unknown): value is JavaVersionId {
return typeof value === 'string' && byId.has(value as JavaVersionId)
}
export function compareMinecraftVersions(a: string, b: string): number {
const aParts = a.split('.').map(Number)
const bParts = b.split('.').map(Number)
for (let index = 0; index < Math.max(aParts.length, bParts.length); index += 1) {
const diff = (aParts[index] ?? 0) - (bParts[index] ?? 0)
if (diff !== 0) return diff > 0 ? 1 : -1
}
return 0
}
export function isVersionAtLeast(version: JavaVersionId, minimum: string): boolean {
return compareMinecraftVersions(version, minimum) >= 0
}
const recipeTypeAvailability: Record<RecipeType, { minVersion: string; maxVersion?: string }> = {
crafting: { minVersion: '1.12' },
smelting: { minVersion: '1.13' },
blasting: { minVersion: '1.14' },
smoking: { minVersion: '1.14' },
campfire_cooking: { minVersion: '1.14' },
stonecutter: { minVersion: '1.14' },
smithing: { minVersion: '1.16', maxVersion: '1.18' },
smithing_trim: { minVersion: '1.19' },
smithing_transform: { minVersion: '1.19' },
}
export const ALL_RECIPE_TYPES: readonly RecipeType[] = [
'crafting',
'smelting',
'blasting',
'smoking',
'campfire_cooking',
'stonecutter',
'smithing',
'smithing_trim',
'smithing_transform',
]
export function isRecipeTypeAvailable(version: JavaVersionId, type: RecipeType): boolean {
const availability = recipeTypeAvailability[type]
if (!isVersionAtLeast(version, availability.minVersion)) return false
if (
availability.maxVersion &&
isVersionAtLeast(version, availability.maxVersion) &&
version !== availability.maxVersion
) {
return false
}
return true
}
export function getSupportedRecipeTypes(version: JavaVersionId): RecipeType[] {
return ALL_RECIPE_TYPES.filter((type) => isRecipeTypeAvailable(version, type))
}
export function coerceRecipeTypeForVersion(
type: RecipeType | undefined,
version: JavaVersionId,
): RecipeType {
const supported = getSupportedRecipeTypes(version)
if (type && supported.includes(type)) return type
return supported[0] ?? 'crafting'
}
export function getRecipeCategoryOptions(type: RecipeType): string[] | undefined {
switch (type) {
case 'crafting':
return ['equipment', 'building', 'misc', 'redstone']
case 'smelting':
return ['food', 'blocks', 'misc']
case 'blasting':
return ['blocks', 'misc']
case 'smoking':
case 'campfire_cooking':
return ['food']
default:
return undefined
}
}
export function supportsRecipeCategory(version: JavaVersionId, type: RecipeType): boolean {
return isVersionAtLeast(version, '1.19') && getRecipeCategoryOptions(type) !== undefined
}
export function supportsShowNotification(
version: JavaVersionId,
type: RecipeType,
shapeless: boolean,
): boolean {
if (type === 'crafting') {
return isVersionAtLeast(version, shapeless ? '26.1' : '1.19')
}
return (
isVersionAtLeast(version, '26.1') &&
[
'smelting',
'blasting',
'smoking',
'campfire_cooking',
'stonecutter',
'smithing_trim',
'smithing_transform',
].includes(type)
)
}
export function supportsSmithingTrimPattern(version: JavaVersionId): boolean {
return isVersionAtLeast(version, '1.21.5')
}
export function supportsItemTags(version: JavaVersionId): boolean {
return isVersionAtLeast(version, '1.13')
}
export function supportsCustomTags(version: JavaVersionId): boolean {
return isVersionAtLeast(version, '1.13')
}
export function supportsVanillaTagList(version: JavaVersionId): boolean {
return isVersionAtLeast(version, '1.14')
}
export const DEFAULT_COOKING_TIME: Record<
'smelting' | 'blasting' | 'smoking' | 'campfire_cooking',
number
> = {
smelting: 200,
blasting: 100,
smoking: 100,
campfire_cooking: 100,
}
export const RESULT_SLOTS_BY_TYPE: Record<RecipeType, readonly RecipeSlot[] | undefined> = {
crafting: ['crafting.result'],
smelting: ['cooking.result'],
blasting: ['cooking.result'],
smoking: ['cooking.result'],
campfire_cooking: ['cooking.result'],
stonecutter: ['stonecutter.result'],
smithing: ['smithing.result'],
smithing_trim: undefined,
smithing_transform: ['smithing.result'],
}
export const SLOT_KEYS_BY_TYPE: Record<RecipeType, readonly RecipeSlot[]> = {
crafting: [
'crafting.1',
'crafting.2',
'crafting.3',
'crafting.4',
'crafting.5',
'crafting.6',
'crafting.7',
'crafting.8',
'crafting.9',
],
smelting: ['cooking.ingredient'],
blasting: ['cooking.ingredient'],
smoking: ['cooking.ingredient'],
campfire_cooking: ['cooking.ingredient'],
stonecutter: ['stonecutter.ingredient'],
smithing: ['smithing.template', 'smithing.base', 'smithing.addition'],
smithing_trim: ['smithing.template', 'smithing.base', 'smithing.addition'],
smithing_transform: ['smithing.template', 'smithing.base', 'smithing.addition'],
}

View File

@ -0,0 +1,66 @@
import { BlocksIcon, BoxIcon, LanguagesIcon, PaletteIcon, PencilIcon, WorldIcon } from '@modrinth/assets'
import type { Component } from 'vue'
export type LabToolDefinition = {
id: string
category: 'creation' | 'maintenance' | 'world'
route: string
icon: Component
title: string
description: string
}
export const labTools: readonly LabToolDefinition[] = [
{
id: 'skin-editor',
category: 'creation',
route: '/lab/skin-editor',
icon: PencilIcon,
title: 'Skin editor',
description: 'Create and edit Minecraft player skins locally.',
},
{
id: 'gradient-text',
category: 'creation',
route: '/lab/gradient-text',
icon: PaletteIcon,
title: 'Gradient text generator',
description: 'Create Minecraft-ready gradient text without a browser.',
},
{
id: 'seed-map',
category: 'world',
route: '/lab/seed-map',
icon: WorldIcon,
title: 'Seed map',
description: 'Explore a Minecraft seed locally with biomes, structures, and saved markers.',
},
{
id: 'schematic-preview',
category: 'creation',
route: '/lab/schematic-preview',
icon: BoxIcon,
title: 'Schematic workshop',
description: 'Quickly preview and edit your schematics.',
},
{
id: 'mod-translation',
category: 'maintenance',
route: '/lab/mod-translation',
icon: LanguagesIcon,
title: 'Mod translation',
description: 'Translate any Minecraft mod JAR into Simplified Chinese.',
},
{
id: 'recipe-generator',
category: 'creation',
route: '/lab/recipe-generator',
icon: BlocksIcon,
title: 'Recipe generator',
description: 'Create Minecraft Java data pack recipes from local item and tag data.',
},
]
export function getLabTool(id: string): LabToolDefinition | undefined {
return labTools.find((tool) => tool.id === id)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,187 @@
import { invoke } from '@tauri-apps/api/core'
export type SchematicPreviewSource =
| { kind: 'external'; path: string }
| { kind: 'instance'; instanceId: string; relativePath: string }
| { kind: 'instance_file'; instanceId: string; relativePath: string }
export type SchematicBlockState = {
name: string
properties: Record<string, string>
}
export type SchematicChunkDescriptor = {
position: [number, number, number]
nonAirBlocks: number
}
export type SchematicRegion = {
id: string
name: string
origin: [number, number, number]
size: [number, number, number]
min: [number, number, number]
max: [number, number, number]
blockCount: number
chunks: SchematicChunkDescriptor[]
}
export type SchematicMaterial = {
name: string
count: number
}
export type SchematicPreviewManifest = {
sessionId: string
fileName: string
sourcePath: string
sourceInstanceId?: string
format: 'litematic' | 'schem_v2' | 'schem_v3'
formatVersion: number
dataVersion?: number
name?: string
description?: string
author?: string
createdAt?: number
modifiedAt?: number
min: [number, number, number]
max: [number, number, number]
size: [number, number, number]
blockCount: number
entityCount: number
blockEntityCount: number
palette: SchematicBlockState[]
materials: SchematicMaterial[]
regions: SchematicRegion[]
warnings: string[]
}
export type InstanceSchematicFile = {
relativePath: string
fileName: string
format: 'litematic' | 'schem'
size: number
modifiedAt?: number
}
export type SchematicBlockEdit = {
regionId: string
position: [number, number, number]
paletteIndex: number
}
export type SchematicChangedChunk = {
regionId: string
position: [number, number, number]
}
export type SchematicEditResult = {
manifest: SchematicPreviewManifest
changedChunks: SchematicChangedChunk[]
}
export type SchematicTransform =
| 'rotate_clockwise'
| 'rotate_counter_clockwise'
| 'mirror_x'
| 'mirror_z'
export async function openSchematicPreview(
source: SchematicPreviewSource,
requestId: string,
): Promise<SchematicPreviewManifest> {
return await invoke<SchematicPreviewManifest>('plugin:schematic-preview|schematic_preview_open', {
source,
requestId,
})
}
export async function listInstanceSchematics(instanceId: string): Promise<InstanceSchematicFile[]> {
return await invoke<InstanceSchematicFile[]>(
'plugin:schematic-preview|schematic_preview_list_instance_files',
{ instanceId },
)
}
export async function readSchematicChunk(
sessionId: string,
regionId: string,
position: [number, number, number],
): Promise<Uint32Array> {
const response = await invoke<ArrayBuffer>(
'plugin:schematic-preview|schematic_preview_read_chunk',
{
sessionId,
regionId,
position,
},
)
const view = new DataView(response)
if (
response.byteLength !== 8 + 4096 * 4 ||
view.getUint8(0) !== 0x53 ||
view.getUint8(1) !== 0x50 ||
view.getUint8(2) !== 0x43 ||
view.getUint8(3) !== 0x31 ||
view.getUint32(4, true) !== 4096
) {
throw new Error('The schematic backend returned an invalid chunk.')
}
const result = new Uint32Array(4096)
for (let index = 0; index < result.length; index += 1) {
result[index] = view.getUint32(8 + index * 4, true)
}
return result
}
export async function getSchematicBlockInfo(
sessionId: string,
regionId: string,
position: [number, number, number],
): Promise<SchematicBlockState | null> {
return await invoke<SchematicBlockState | null>(
'plugin:schematic-preview|schematic_preview_block_info',
{ sessionId, regionId, position },
)
}
export async function applySchematicEdits(
sessionId: string,
edits: SchematicBlockEdit[],
targetState?: SchematicBlockState,
): Promise<SchematicEditResult> {
return await invoke<SchematicEditResult>(
'plugin:schematic-preview|schematic_preview_apply_edits',
{ sessionId, edits, targetState: targetState ?? null },
)
}
export async function exportSchematicSponge(sessionId: string): Promise<ArrayBuffer> {
return await invoke<ArrayBuffer>('plugin:schematic-preview|schematic_preview_export_sponge', {
sessionId,
})
}
export async function exportSchematicLitematic(sessionId: string): Promise<ArrayBuffer> {
return await invoke<ArrayBuffer>('plugin:schematic-preview|schematic_preview_export_litematic', {
sessionId,
})
}
export async function transformSchematic(
sessionId: string,
transform: SchematicTransform,
): Promise<SchematicPreviewManifest> {
return await invoke<SchematicPreviewManifest>(
'plugin:schematic-preview|schematic_preview_transform',
{ sessionId, transform },
)
}
export async function closeSchematicPreview(sessionId: string): Promise<void> {
await invoke('plugin:schematic-preview|schematic_preview_close', { sessionId })
}
export async function cancelSchematicPreview(requestId: string): Promise<void> {
await invoke('plugin:schematic-preview|schematic_preview_cancel', { requestId })
}

View File

@ -0,0 +1,14 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { projectSchematicBlockPreviewPosition } from './block-preview.ts'
test('full block previews are not vertically compressed', () => {
const corners = Array.from({ length: 8 }, (_, index) =>
projectSchematicBlockPreviewPosition([index & 1, (index >> 1) & 1, (index >> 2) & 1]),
)
const width = Math.max(...corners.map(({ x }) => x)) - Math.min(...corners.map(({ x }) => x))
const height = Math.max(...corners.map(({ y }) => y)) - Math.min(...corners.map(({ y }) => y))
assert.ok(height > width)
})

View File

@ -0,0 +1,258 @@
import { BlockDefinition, BlockModel, Cull, Identifier, type TextureAtlasProvider } from 'deepslate'
import type { SchematicBlockState } from './backend'
import type { LoadedSchematicResources, SchematicWorkerResources } from './resources'
type PreviewVertex = {
position: [number, number, number]
texture?: [number, number]
color: [number, number, number]
}
type PreparedResources = {
definitions: Map<string, BlockDefinition | null>
models: Map<string, BlockModel | null>
modelProvider: {
getBlockModel: (id: Identifier) => BlockModel | null
}
}
const preparedResourceCache = new WeakMap<SchematicWorkerResources, PreparedResources>()
const previewCameraYaw = Math.PI / 4
const previewCameraPitch = Math.PI / 6
function prepareResources(resources: SchematicWorkerResources) {
const cached = preparedResourceCache.get(resources)
if (cached) return cached
const prepared: PreparedResources = {
definitions: new Map(),
models: new Map(),
modelProvider: {
getBlockModel(id) {
const key = id.toString()
if (prepared.models.has(key)) return prepared.models.get(key) ?? null
const source = resources.blockModels[key]
if (!source) {
prepared.models.set(key, null)
return null
}
try {
const model = BlockModel.fromJson(source)
prepared.models.set(key, model)
model.flatten(prepared.modelProvider)
return model
} catch {
prepared.models.set(key, null)
return null
}
},
},
}
preparedResourceCache.set(resources, prepared)
return prepared
}
function blockDefinition(blockName: string, resources: SchematicWorkerResources) {
const prepared = prepareResources(resources)
if (prepared.definitions.has(blockName)) return prepared.definitions.get(blockName) ?? null
const source = resources.blockDefinitions[blockName]
if (!source) {
prepared.definitions.set(blockName, null)
return null
}
try {
const definition = BlockDefinition.fromJson(source)
prepared.definitions.set(blockName, definition)
return definition
} catch {
prepared.definitions.set(blockName, null)
return null
}
}
export function projectSchematicBlockPreviewPosition(position: [number, number, number]) {
const horizontal = Math.cos(previewCameraYaw)
const depth = Math.sin(previewCameraYaw)
return {
x: position[0] * horizontal - position[2] * depth,
y:
(position[0] * depth + position[2] * horizontal) * Math.sin(previewCameraPitch) -
position[1] * Math.cos(previewCameraPitch),
}
}
function triangleShade(vertices: [PreviewVertex, PreviewVertex, PreviewVertex]) {
const [first, second, third] = vertices.map((vertex) => vertex.position)
const edgeA = first.map((value, index) => second[index] - value)
const edgeB = first.map((value, index) => third[index] - value)
const normal = [
edgeA[1] * edgeB[2] - edgeA[2] * edgeB[1],
edgeA[2] * edgeB[0] - edgeA[0] * edgeB[2],
edgeA[0] * edgeB[1] - edgeA[1] * edgeB[0],
]
const length = Math.hypot(...normal) || 1
const light = [0.42, 0.82, 0.39]
return (
0.66 +
0.34 * Math.abs(normal.reduce((sum, value, index) => sum + value * light[index], 0) / length)
)
}
function previewTriangles(state: SchematicBlockState, resources: SchematicWorkerResources) {
const definition = blockDefinition(state.name, resources)
if (!definition) return []
const prepared = prepareResources(resources)
const atlas: TextureAtlasProvider = {
getTextureAtlas: () => ({}) as ImageData,
getTextureUV: (id) => resources.textureUvs[id.toString()] ?? resources.missingTextureUv,
}
try {
const properties = {
...(resources.defaultBlockProperties[state.name] ?? {}),
...state.properties,
}
const mesh = definition.getMesh(
Identifier.parse(state.name),
properties,
atlas,
prepared.modelProvider,
Cull.none(),
)
return mesh.quads.flatMap((quad) => {
const vertices = quad.vertices().map(
(vertex): PreviewVertex => ({
position: [vertex.pos.x, vertex.pos.y, vertex.pos.z],
texture: vertex.texture ? [...vertex.texture] : undefined,
color: [vertex.color[0], vertex.color[1], vertex.color[2]],
}),
)
return [
[vertices[0], vertices[1], vertices[2]],
[vertices[0], vertices[2], vertices[3]],
].map((triangle) => ({
vertices: triangle as [PreviewVertex, PreviewVertex, PreviewVertex],
depth:
triangle.reduce(
(sum, vertex) =>
sum + vertex.position[0] + vertex.position[1] * 0.7 + vertex.position[2],
0,
) / 3,
shade: triangleShade(triangle as [PreviewVertex, PreviewVertex, PreviewVertex]),
}))
})
} catch {
return []
}
}
function affineTransform(
source: [[number, number], [number, number], [number, number]],
target: [[number, number], [number, number], [number, number]],
) {
const [[x0, y0], [x1, y1], [x2, y2]] = source
const determinant = x0 * (y1 - y2) + x1 * (y2 - y0) + x2 * (y0 - y1)
if (Math.abs(determinant) < 0.0001) return undefined
const coefficient = (values: [number, number, number]) => ({
first: (values[0] * (y1 - y2) + values[1] * (y2 - y0) + values[2] * (y0 - y1)) / determinant,
second: (values[0] * (x2 - x1) + values[1] * (x0 - x2) + values[2] * (x1 - x0)) / determinant,
offset:
(values[0] * (x1 * y2 - x2 * y1) +
values[1] * (x2 * y0 - x0 * y2) +
values[2] * (x0 * y1 - x1 * y0)) /
determinant,
})
const horizontal = coefficient([target[0][0], target[1][0], target[2][0]])
const vertical = coefficient([target[0][1], target[1][1], target[2][1]])
return [
horizontal.first,
vertical.first,
horizontal.second,
vertical.second,
horizontal.offset,
vertical.offset,
] as const
}
function traceTriangle(context: CanvasRenderingContext2D, points: [number, number][]) {
context.beginPath()
context.moveTo(...points[0])
context.lineTo(...points[1])
context.lineTo(...points[2])
context.closePath()
}
export function renderSchematicBlockPreview(
target: HTMLCanvasElement,
state: SchematicBlockState,
resources: LoadedSchematicResources,
) {
const triangles = previewTriangles(state, resources.previewResources)
if (triangles.length === 0) return false
const projected = triangles.flatMap((triangle) =>
triangle.vertices.map((vertex) => projectSchematicBlockPreviewPosition(vertex.position)),
)
const minX = Math.min(...projected.map((point) => point.x))
const maxX = Math.max(...projected.map((point) => point.x))
const minY = Math.min(...projected.map((point) => point.y))
const maxY = Math.max(...projected.map((point) => point.y))
const padding = target.width * 0.1
const scale = Math.min(
(target.width - padding * 2) / Math.max(0.1, maxX - minX),
(target.height - padding * 2) / Math.max(0.1, maxY - minY),
)
const offsetX = (target.width - (minX + maxX) * scale) / 2
const offsetY = (target.height - (minY + maxY) * scale) / 2
const context = target.getContext('2d')
if (!context) return false
context.clearRect(0, 0, target.width, target.height)
context.imageSmoothingEnabled = false
for (const triangle of triangles.sort((left, right) => left.depth - right.depth)) {
const points = triangle.vertices.map((vertex) => {
const point = projectSchematicBlockPreviewPosition(vertex.position)
return [point.x * scale + offsetX, point.y * scale + offsetY] as [number, number]
})
const texture = triangle.vertices.map((vertex) => vertex.texture)
let drewTexture = false
if (texture.every((value): value is [number, number] => value !== undefined)) {
const transform = affineTransform(
texture.map(([u, v]) => [u * resources.atlas.width, v * resources.atlas.height]) as [
[number, number],
[number, number],
[number, number],
],
points as [[number, number], [number, number], [number, number]],
)
if (transform) {
context.save()
traceTriangle(context, points)
context.clip()
context.setTransform(...transform)
context.drawImage(resources.atlas, 0, 0)
context.restore()
drewTexture = true
}
}
const color = triangle.vertices
.reduce(
(result, vertex) => result.map((value, index) => value + vertex.color[index]),
[0, 0, 0],
)
.map((value) => Math.round((value / 3) * 255))
context.save()
traceTriangle(context, points)
context.clip()
if (!drewTexture) {
context.fillStyle = '#8c9692'
context.fillRect(0, 0, target.width, target.height)
}
context.globalCompositeOperation = 'multiply'
context.fillStyle = `rgb(${color.join(' ')})`
context.fillRect(0, 0, target.width, target.height)
context.globalCompositeOperation = 'source-atop'
context.fillStyle = `rgb(0 0 0 / ${1 - triangle.shade})`
context.fillRect(0, 0, target.width, target.height)
context.restore()
}
return true
}

View File

@ -0,0 +1,208 @@
import assert from 'node:assert/strict'
import { after, before, test } from 'node:test'
import { fileURLToPath } from 'node:url'
import { BlockDefinition, BlockModel, Cull, Identifier } from 'deepslate'
import { createServer, type ViteDevServer } from 'vite'
import type { SchematicBlockState } from './backend.ts'
import { getSchematicMeshOcclusionFaces, isSchematicOccluding } from './meshing.ts'
type BuiltinResourcesModule = typeof import('./builtin-resources.ts')
let server: ViteDevServer
let builtin: BuiltinResourcesModule
before(async () => {
const appRoot = fileURLToPath(new URL('../../../', import.meta.url))
server = await createServer({
root: appRoot,
configFile: `${appRoot}/vite.config.ts`,
server: { middlewareMode: true, hmr: false },
appType: 'custom',
})
builtin = (await server.ssrLoadModule(
'/src/lab/schematic-preview/builtin-resources.ts',
)) as BuiltinResourcesModule
})
after(async () => {
await server.close()
})
test('built-in assets render stained glass and sugar cane across supported versions', () => {
const palette: SchematicBlockState[] = [
{ name: 'minecraft:air', properties: {} },
{ name: 'minecraft:white_stained_glass', properties: {} },
{ name: 'minecraft:sugar_cane', properties: {} },
]
for (const version of ['1.21', '26.2']) {
const resources = builtin.loadBuiltinBlockResources(version, palette)
assert.equal(resources.defaultBlockProperties['minecraft:sugar_cane']?.age, '0')
const models = Object.fromEntries(
Object.entries(resources.blockModels).map(([id, model]) => [id, BlockModel.fromJson(model)]),
)
const modelProvider = {
getBlockModel: (id: Identifier) => models[id.toString()] ?? null,
}
for (const model of Object.values(models)) model.flatten(modelProvider)
const textureUvs = builtin.builtinTextureUvs()
for (const blockName of ['white_stained_glass', 'sugar_cane']) {
const state = palette.find((entry) => entry.name === `minecraft:${blockName}`)
assert.ok(state)
assert.ok(textureUvs[`minecraft:block/${blockName}`])
const definition = BlockDefinition.fromJson(resources.blockDefinitions[state.name])
const mesh = definition.getMesh(
Identifier.parse(state.name),
state.properties,
{
getTextureAtlas: () => ({}) as ImageData,
getTextureUV: (id) => textureUvs[id.toString()] ?? [0, 0, 0, 0],
},
modelProvider,
Cull.none(),
)
assert.ok(mesh.quads.length > 0, `${version} generated no mesh for ${state.name}`)
}
}
})
test('built-in assets expose the complete renderer resource set', () => {
const textureUvs = builtin.builtinTextureUvs()
assert.ok(Object.keys(textureUvs).length >= 2600)
assert.ok(textureUvs['minecraft:block/white_stained_glass'])
assert.ok(textureUvs['minecraft:block/sugar_cane'])
assert.deepEqual(builtin.builtinTextureRegion('minecraft:block/white_stained_glass'), {
x: 80,
y: 1952,
width: 16,
height: 16,
})
})
test('all built-in block resources remain compatible with Deepslate', () => {
const palette = builtin.listBuiltinBlockStates()
const resources = builtin.loadBuiltinBlockResources('26.3-snapshot-6', palette)
const definitions = Object.fromEntries(
Object.entries(resources.blockDefinitions).map(([id, definition]) => [
id,
BlockDefinition.fromJson(definition),
]),
)
const models = Object.fromEntries(
Object.entries(resources.blockModels).map(([id, model]) => [id, BlockModel.fromJson(model)]),
)
const modelProvider = {
getBlockModel: (id: Identifier) => models[id.toString()] ?? null,
}
for (const model of Object.values(models)) model.flatten(modelProvider)
const textureUvs = builtin.builtinTextureUvs()
const atlasProvider = {
getTextureAtlas: () => ({}) as ImageData,
getTextureUV: (id: Identifier) => textureUvs[id.toString()] ?? [0, 0, 0, 0],
}
for (const state of palette) {
try {
definitions[state.name]?.getMesh(
Identifier.parse(state.name),
state.properties,
atlasProvider,
modelProvider,
Cull.none(),
)
} catch (error) {
throw new Error(`Unable to render built-in block ${state.name}`, { cause: error })
}
}
})
test('partial redstone models do not hide every face of adjacent blocks', () => {
const reportedNames = [
'minecraft:observer',
'minecraft:redstone_wire',
'minecraft:lantern',
'minecraft:hopper',
'minecraft:repeater',
'minecraft:piston',
]
const availableStates = builtin.listBuiltinBlockStates()
const palette = [
{ name: 'minecraft:air', properties: {} },
...['minecraft:stone', ...reportedNames].map((name) => {
const state = availableStates.find((entry) => entry.name === name)
assert.ok(state)
return name === 'minecraft:piston'
? { ...state, properties: { ...state.properties, extended: 'true' } }
: state
}),
]
const resources = builtin.loadBuiltinBlockResources('26.3-snapshot-6', palette)
const definitions = Object.fromEntries(
Object.entries(resources.blockDefinitions).map(([id, definition]) => [
id,
BlockDefinition.fromJson(definition),
]),
)
const models = Object.fromEntries(
Object.entries(resources.blockModels).map(([id, model]) => [id, BlockModel.fromJson(model)]),
)
const modelProvider = {
getBlockModel: (id: Identifier) => models[id.toString()] ?? null,
}
for (const model of Object.values(models)) model.flatten(modelProvider)
const atlasProvider = {
getTextureAtlas: () => ({}) as ImageData,
getTextureUV: () => [0, 0, 1, 1] as [number, number, number, number],
}
const occlusionFaces = (state: SchematicBlockState) => {
const paletteIndex = palette.indexOf(state)
if (!isSchematicOccluding(paletteIndex, palette)) return {}
const properties = {
...(resources.defaultBlockProperties[state.name] ?? {}),
...state.properties,
}
const mesh = definitions[state.name].getMesh(
Identifier.parse(state.name),
properties,
atlasProvider,
modelProvider,
Cull.none(),
)
return getSchematicMeshOcclusionFaces(mesh)
}
assert.equal(Object.keys(occlusionFaces(palette[1])).length, 6)
for (const state of palette.slice(2)) {
assert.ok(
Object.keys(occlusionFaces(state)).length < 6,
`${state.name} was treated as a full occluding cube`,
)
}
})
test('external models inherit missing vanilla parents from built-in assets', () => {
const blockDefinitions: Record<string, unknown> = {
'example:machine': { variants: { '': { model: 'example:block/machine' } } },
}
const blockModels: Record<string, unknown> = {
'example:block/machine': {
parent: 'minecraft:block/cube_all',
textures: { all: 'minecraft:block/white_stained_glass' },
},
}
builtin.completeBuiltinModelDependencies('26.2', blockDefinitions, blockModels)
assert.ok(blockModels['minecraft:block/cube_all'])
})
test('unqualified model parents use the vanilla namespace', () => {
const blockDefinitions: Record<string, unknown> = {
'example:machine': { variants: { '': { model: 'example:block/machine' } } },
}
const blockModels: Record<string, unknown> = {
'example:block/machine': { parent: 'block/cube_all' },
}
builtin.completeBuiltinModelDependencies('26.2', blockDefinitions, blockModels)
assert.ok(blockModels['minecraft:block/cube_all'])
})

View File

@ -0,0 +1,197 @@
import blockModelsData from './assets/vanilla/block-model-index.json'
import blockNameData from './assets/vanilla/block-name-index.json'
import blockDefaultPropertiesData from './assets/vanilla/block-property-defaults.json'
import blockDefinitionsData from './assets/vanilla/block-state-index.json'
import blocksAtlasUrl from './assets/vanilla/texture-atlas.png?url'
import atlasUvData from './assets/vanilla/texture-layout.json'
import type { SchematicBlockState } from './backend.ts'
type JsonObject = Record<string, unknown>
type RawTextureRegion = [number, number, number, number]
export type BuiltinTextureRegion = {
x: number
y: number
width: number
height: number
}
const blockDefinitions = blockDefinitionsData as Record<string, unknown>
const blockModels = blockModelsData as Record<string, unknown>
const blockDefaultProperties = blockDefaultPropertiesData as Record<string, Record<string, string>>
const atlasUvs = atlasUvData as Record<string, RawTextureRegion>
const blockNames = blockNameData as {
en_us: Record<string, string>
zh_cn: Record<string, string>
}
const BLOCK_ALIASES: Record<string, string> = {
trapdoor: 'oak_trapdoor',
chain: 'iron_chain',
grass_path: 'dirt_path',
grass: 'short_grass',
sign: 'oak_sign',
wall_sign: 'oak_wall_sign',
banner: 'white_banner',
wall_banner: 'white_wall_banner',
bed: 'red_bed',
skull: 'skeleton_skull',
wall_skull: 'skeleton_wall_skull',
}
function identifier(value: string) {
const normalized = value.trim().toLowerCase()
return normalized.includes(':') ? normalized : `minecraft:${normalized}`
}
function minecraftPath(value: string) {
const normalized = value.trim().toLowerCase()
const [namespace, path] = normalized.includes(':')
? (normalized.split(':', 2) as [string, string])
: ['minecraft', normalized]
if (namespace !== 'minecraft') return undefined
return BLOCK_ALIASES[path] ?? path
}
function collectModelReferences(value: unknown, references: Set<string>) {
if (Array.isArray(value)) {
for (const item of value) collectModelReferences(item, references)
return
}
if (!value || typeof value !== 'object') return
for (const [key, item] of Object.entries(value as JsonObject)) {
if (key === 'model' && typeof item === 'string') {
references.add(identifier(item))
} else {
collectModelReferences(item, references)
}
}
}
function builtinModel(modelId: string) {
const [namespace, path] = modelId.split(':', 2)
const model = namespace === 'minecraft' && path ? blockModels[path] : undefined
if (!model || typeof model !== 'object' || Array.isArray(model)) return model
const textures = (model as JsonObject).textures
if (!textures || typeof textures !== 'object' || Array.isArray(textures)) return model
return {
...(model as JsonObject),
textures: Object.fromEntries(
Object.entries(textures).map(([key, value]) => [
key,
value && typeof value === 'object' && !Array.isArray(value)
? ((value as JsonObject).sprite ?? value)
: value,
]),
),
}
}
export function completeBuiltinModelDependencies(
_version: string,
definitions: Record<string, unknown>,
models: Record<string, unknown>,
) {
const pendingModels = new Set<string>()
for (const definition of Object.values(definitions)) {
collectModelReferences(definition, pendingModels)
}
const visitedModels = new Set<string>()
while (pendingModels.size > 0) {
const modelId = pendingModels.values().next().value as string | undefined
if (!modelId) break
pendingModels.delete(modelId)
if (visitedModels.has(modelId)) continue
visitedModels.add(modelId)
let model = models[modelId]
if (!model) {
model = builtinModel(modelId)
if (model) models[modelId] = model
}
if (!model || typeof model !== 'object' || Array.isArray(model)) continue
const parent = (model as JsonObject).parent
if (typeof parent === 'string') pendingModels.add(identifier(parent))
}
}
export function loadBuiltinBlockResources(
version: string,
palette: readonly SchematicBlockState[],
) {
const definitions: Record<string, unknown> = {}
const models: Record<string, unknown> = {}
const defaultProperties: Record<string, Record<string, string>> = {}
for (const state of palette) {
const path = minecraftPath(state.name)
if (!path) continue
const definition = blockDefinitions[path]
if (definition) definitions[state.name] = definition
const defaults = blockDefaultProperties[path]
if (defaults) defaultProperties[state.name] = defaults
}
completeBuiltinModelDependencies(version, definitions, models)
return {
blockDefinitions: definitions,
blockModels: models,
defaultBlockProperties: defaultProperties,
}
}
export function listBuiltinBlockStates(): SchematicBlockState[] {
return Object.keys(blockDefinitions).map((path) => ({
name: `minecraft:${path}`,
properties: { ...(blockDefaultProperties[path] ?? {}) },
}))
}
export function loadBuiltinBlockNames() {
return {
en_us: { ...blockNames.en_us },
zh_cn: { ...blockNames.zh_cn },
}
}
function normalizeTextureId(value: string) {
const normalized = value.trim().toLowerCase()
return normalized.includes(':') ? normalized : `minecraft:${normalized}`
}
export function builtinTextureRegion(textureId: string): BuiltinTextureRegion | undefined {
const normalized = normalizeTextureId(textureId)
const [namespace, path] = normalized.split(':', 2)
if (namespace !== 'minecraft' || !path) return undefined
const region = atlasUvs[path]
if (!region) return undefined
return { x: region[0], y: region[1], width: region[2], height: region[3] }
}
export function builtinTextureUvs(
canvasWidth = BUILTIN_ATLAS_WIDTH,
canvasHeight = BUILTIN_ATLAS_HEIGHT,
) {
const textureUvs: Record<string, [number, number, number, number]> = {}
for (const [name, region] of Object.entries(atlasUvs)) {
const visibleHeight = Math.min(region[2], region[3])
textureUvs[`minecraft:${name}`] = [
region[0] / canvasWidth,
region[1] / canvasHeight,
(region[0] + region[2]) / canvasWidth,
(region[1] + visibleHeight) / canvasHeight,
]
}
return textureUvs
}
export async function loadBuiltinAtlas() {
const response = await fetch(blocksAtlasUrl)
if (!response.ok) throw new Error('Unable to load the built-in Minecraft texture atlas.')
return await createImageBitmap(await response.blob())
}
export const BUILTIN_ATLAS_WIDTH = 2048
export const BUILTIN_ATLAS_IMAGE_HEIGHT = 2128
export const BUILTIN_ATLAS_HEIGHT = 4096

View File

@ -0,0 +1,115 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
filterSchematicAirGeometry,
isSchematicAir,
measureSchematicPoints,
normalizeSchematicAirBlocks,
schematicBlockPaletteIndex,
type SchematicCachedChunk,
schematicChunkKey,
selectConnectedSchematicBlocks,
selectSchematicCuboid,
selectSchematicLayer,
selectSchematicMaterial,
} from './editing.ts'
test('air block detection tolerates noncanonical casing and whitespace', () => {
assert.equal(isSchematicAir(' Minecraft:Air '), true)
assert.equal(isSchematicAir('minecraft:CAVE_AIR'), true)
assert.equal(isSchematicAir('minecraft:void_air'), true)
assert.equal(isSchematicAir('minecraft:light'), true)
assert.equal(isSchematicAir('minecraft:barrier'), true)
assert.equal(isSchematicAir('minecraft:structure_void'), true)
assert.equal(isSchematicAir('minecraft:stone'), false)
})
test('chunk normalization collapses every air palette entry to index zero', () => {
const blocks = new Uint32Array([1, 2, 3, 2])
const normalized = normalizeSchematicAirBlocks(blocks, [
{ name: 'minecraft:air', properties: {} },
{ name: 'minecraft:stone', properties: {} },
{ name: ' Minecraft:CAVE_AIR ', properties: {} },
{ name: 'minecraft:void_air', properties: {} },
{ name: 'minecraft:barrier', properties: {} },
])
assert.deepEqual([...normalized], [1, 0, 0, 0])
assert.deepEqual([...blocks], [1, 2, 3, 2])
})
test('mesh validation removes stale geometry whose cached block is air', () => {
const chunk: SchematicCachedChunk = {
regionId: 'region-0',
position: [0, 0, 0],
blocks: new Uint32Array(4096),
}
chunk.blocks[1] = 1
const mesh = {
positions: new Float32Array([0, 0, 0, 1, 0, 0]),
normals: new Float32Array([0, 1, 0, 0, 1, 0]),
uvs: new Float32Array([0, 0, 1, 0]),
colors: new Float32Array([1, 1, 1, 1, 1, 1]),
blockPositions: new Float32Array([0, 0, 0, 1, 0, 0]),
}
const filtered = filterSchematicAirGeometry(mesh, chunk, [
{ name: 'minecraft:air', properties: {} },
{ name: 'minecraft:stone', properties: {} },
])
assert.deepEqual([...filtered.blockPositions], [1, 0, 0])
assert.deepEqual([...filtered.positions], [1, 0, 0])
})
test('point measurement reports signed offsets, inclusive size, and distance', () => {
const measurement = measureSchematicPoints([4, -2, 10], [-2, 1, 2])
assert.deepEqual(measurement.delta, [-6, 3, -8])
assert.deepEqual(measurement.size, [7, 4, 9])
assert.equal(measurement.distance, Math.sqrt(109))
})
function fixture() {
const blocks = new Uint32Array(4096)
blocks[0] = 1
blocks[1] = 1
blocks[16] = 2
blocks[256] = 2
const chunk: SchematicCachedChunk = { regionId: 'region-0', position: [-1, 0, 0], blocks }
return new Map([[schematicChunkKey(chunk.regionId, chunk.position), chunk]])
}
test('palette lookup handles negative chunk coordinates', () => {
assert.equal(
schematicBlockPaletteIndex(fixture(), { regionId: 'region-0', position: [-16, 0, 0] }),
1,
)
})
test('cuboid selection skips air and keeps the selected region', () => {
const selected = selectSchematicCuboid(
fixture(),
{ regionId: 'region-0', position: [-16, 0, 0] },
{ regionId: 'region-0', position: [-15, 1, 1] },
)
assert.equal(selected.length, 4)
assert.ok(selected.every((location) => location.regionId === 'region-0'))
})
test('material, layer, and connected expansion use cached block data', () => {
const chunks = fixture()
const palette = [
{ name: 'minecraft:air', properties: {} },
{ name: 'minecraft:stone', properties: {} },
{ name: 'minecraft:dirt', properties: {} },
]
assert.equal(selectSchematicMaterial(chunks, palette, 'minecraft:dirt').length, 2)
assert.equal(selectSchematicLayer(chunks, 0).length, 3)
assert.equal(
selectConnectedSchematicBlocks(chunks, {
regionId: 'region-0',
position: [-16, 0, 0],
}).length,
4,
)
})

View File

@ -0,0 +1,256 @@
import type { SchematicBlockState } from './backend.ts'
export type SchematicBlockLocation = {
regionId: string
position: [number, number, number]
}
export type SchematicCachedChunk = {
regionId: string
position: [number, number, number]
blocks: Uint32Array
}
export type SchematicMeshArrays = {
positions: Float32Array
normals: Float32Array
uvs: Float32Array
colors: Float32Array
blockPositions: Float32Array
}
export const MAX_SCHEMATIC_SELECTION = 250_000
const SCHEMATIC_AIR_BLOCKS = new Set([
'minecraft:air',
'minecraft:cave_air',
'minecraft:void_air',
'minecraft:light',
'minecraft:barrier',
'minecraft:structure_void',
])
export function isSchematicAir(name: string) {
return SCHEMATIC_AIR_BLOCKS.has(name.trim().toLowerCase())
}
export function normalizeSchematicAirBlocks(
blocks: Uint32Array,
palette: readonly SchematicBlockState[],
) {
const airIndexes = new Set<number>()
for (let index = 1; index < palette.length; index += 1) {
if (isSchematicAir(palette[index]?.name ?? '')) airIndexes.add(index)
}
if (airIndexes.size === 0) return blocks
let normalized: Uint32Array | undefined
for (let index = 0; index < blocks.length; index += 1) {
if (!airIndexes.has(blocks[index] ?? 0)) continue
normalized ??= blocks.slice()
normalized[index] = 0
}
return normalized ?? blocks
}
export function filterSchematicAirGeometry(
mesh: SchematicMeshArrays,
chunk: SchematicCachedChunk,
palette: readonly SchematicBlockState[],
) {
const keep = new Uint8Array(mesh.blockPositions.length / 3)
let keptVertices = 0
const origin = chunk.position.map((value) => value * 16)
for (let vertex = 0; vertex < keep.length; vertex += 1) {
const x = Math.round(mesh.blockPositions[vertex * 3] ?? 0) - origin[0]
const y = Math.round(mesh.blockPositions[vertex * 3 + 1] ?? 0) - origin[1]
const z = Math.round(mesh.blockPositions[vertex * 3 + 2] ?? 0) - origin[2]
const blockIndex = y * 256 + z * 16 + x
const state = palette[chunk.blocks[blockIndex] ?? 0]
if (x < 0 || x >= 16 || y < 0 || y >= 16 || z < 0 || z >= 16 || !state) continue
if (isSchematicAir(state.name)) continue
keep[vertex] = 1
keptVertices += 1
}
if (keptVertices === keep.length) return mesh
const positions = new Float32Array(keptVertices * 3)
const normals = new Float32Array(keptVertices * 3)
const uvs = new Float32Array(keptVertices * 2)
const colors = new Float32Array(keptVertices * 3)
const blockPositions = new Float32Array(keptVertices * 3)
let target = 0
for (let vertex = 0; vertex < keep.length; vertex += 1) {
if (!keep[vertex]) continue
positions.set(mesh.positions.subarray(vertex * 3, vertex * 3 + 3), target * 3)
normals.set(mesh.normals.subarray(vertex * 3, vertex * 3 + 3), target * 3)
uvs.set(mesh.uvs.subarray(vertex * 2, vertex * 2 + 2), target * 2)
colors.set(mesh.colors.subarray(vertex * 3, vertex * 3 + 3), target * 3)
blockPositions.set(mesh.blockPositions.subarray(vertex * 3, vertex * 3 + 3), target * 3)
target += 1
}
return { positions, normals, uvs, colors, blockPositions }
}
export function measureSchematicPoints(
from: [number, number, number],
to: [number, number, number],
) {
const delta = to.map((value, axis) => value - from[axis]) as [number, number, number]
const size = delta.map((value) => Math.abs(value) + 1) as [number, number, number]
return { delta, size, distance: Math.hypot(...delta) }
}
export function schematicChunkKey(regionId: string, position: [number, number, number]) {
return `${regionId}\u0000${position.join(':')}`
}
export function schematicBlockKey(location: SchematicBlockLocation) {
return `${location.regionId}\u0000${location.position.join(':')}`
}
export function schematicBlockPaletteIndex(
chunks: ReadonlyMap<string, SchematicCachedChunk>,
location: SchematicBlockLocation,
) {
const chunkPosition = location.position.map((value) => Math.floor(value / 16)) as [
number,
number,
number,
]
const chunk = chunks.get(schematicChunkKey(location.regionId, chunkPosition))
if (!chunk) return 0
const local = location.position.map((value) => ((value % 16) + 16) % 16)
return chunk.blocks[local[1] * 256 + local[2] * 16 + local[0]] ?? 0
}
function pushLocation(
result: SchematicBlockLocation[],
regionId: string,
position: [number, number, number],
) {
if (result.length >= MAX_SCHEMATIC_SELECTION) return false
result.push({ regionId, position })
return true
}
export function selectSchematicCuboid(
chunks: ReadonlyMap<string, SchematicCachedChunk>,
from: SchematicBlockLocation,
to: SchematicBlockLocation,
) {
if (from.regionId !== to.regionId) return [to]
const min = from.position.map((value, axis) => Math.min(value, to.position[axis]))
const max = from.position.map((value, axis) => Math.max(value, to.position[axis]))
const result: SchematicBlockLocation[] = []
for (let y = min[1]; y <= max[1]; y += 1) {
for (let z = min[2]; z <= max[2]; z += 1) {
for (let x = min[0]; x <= max[0]; x += 1) {
const location: SchematicBlockLocation = {
regionId: from.regionId,
position: [x, y, z],
}
if (
schematicBlockPaletteIndex(chunks, location) !== 0 &&
!pushLocation(result, location.regionId, location.position)
) {
return result
}
}
}
}
return result
}
export function selectSchematicBlocks(
chunks: ReadonlyMap<string, SchematicCachedChunk>,
predicate: (
paletteIndex: number,
position: [number, number, number],
regionId: string,
) => boolean,
) {
const result: SchematicBlockLocation[] = []
for (const chunk of chunks.values()) {
for (let index = 0; index < chunk.blocks.length; index += 1) {
const paletteIndex = chunk.blocks[index] ?? 0
if (paletteIndex === 0) continue
const x = index % 16
const z = Math.floor(index / 16) % 16
const y = Math.floor(index / 256)
const position: [number, number, number] = [
chunk.position[0] * 16 + x,
chunk.position[1] * 16 + y,
chunk.position[2] * 16 + z,
]
if (
predicate(paletteIndex, position, chunk.regionId) &&
!pushLocation(result, chunk.regionId, position)
) {
return result
}
}
}
return result
}
export function selectSchematicMaterial(
chunks: ReadonlyMap<string, SchematicCachedChunk>,
palette: SchematicBlockState[],
name: string,
) {
return selectSchematicBlocks(chunks, (paletteIndex) => palette[paletteIndex]?.name === name)
}
export function selectSchematicLayer(chunks: ReadonlyMap<string, SchematicCachedChunk>, y: number) {
return selectSchematicBlocks(chunks, (_paletteIndex, position) => position[1] === y)
}
export function selectConnectedSchematicBlocks(
chunks: ReadonlyMap<string, SchematicCachedChunk>,
start: SchematicBlockLocation,
) {
if (schematicBlockPaletteIndex(chunks, start) === 0) return []
const result: SchematicBlockLocation[] = []
const queue = [start]
const visited = new Set([schematicBlockKey(start)])
const offsets = [
[-1, 0, 0],
[1, 0, 0],
[0, -1, 0],
[0, 1, 0],
[0, 0, -1],
[0, 0, 1],
] as const
for (let index = 0; index < queue.length && result.length < MAX_SCHEMATIC_SELECTION; index += 1) {
const current = queue[index]
if (!current) break
result.push(current)
for (const offset of offsets) {
const next: SchematicBlockLocation = {
regionId: start.regionId,
position: [
current.position[0] + offset[0],
current.position[1] + offset[1],
current.position[2] + offset[2],
],
}
const key = schematicBlockKey(next)
if (visited.has(key) || schematicBlockPaletteIndex(chunks, next) === 0) continue
visited.add(key)
queue.push(next)
}
}
return result
}
export function schematicSelectionBounds(selection: SchematicBlockLocation[]) {
if (selection.length === 0) return undefined
const min = [...selection[0].position] as [number, number, number]
const max = [...selection[0].position] as [number, number, number]
for (const location of selection.slice(1)) {
for (let axis = 0; axis < 3; axis += 1) {
min[axis] = Math.min(min[axis], location.position[axis])
max[axis] = Math.max(max[axis], location.position[axis])
}
}
return { min, max }
}

View File

@ -0,0 +1,125 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { InstanceSchematicFile } from './backend.ts'
import {
buildInstanceSchematicRows,
collectSchematicFolders,
type InstanceSchematicFolderRow,
} from './instance-files.ts'
function schematicFile(relativePath: string): InstanceSchematicFile {
const segments = relativePath.split(/[\\/]/)
return {
relativePath,
fileName: segments[segments.length - 1] ?? relativePath,
format: relativePath.toLocaleLowerCase().endsWith('.schem') ? 'schem' : 'litematic',
size: 1,
}
}
function fileRows(rows: ReturnType<typeof buildInstanceSchematicRows>) {
return rows.filter((row) => row.kind === 'file')
}
function folderRows(rows: ReturnType<typeof buildInstanceSchematicRows>) {
return rows.filter((row): row is InstanceSchematicFolderRow => row.kind === 'folder')
}
test('root files and nested folders produce collapsible folder rows', () => {
const files = [
schematicFile('house.litematic'),
schematicFile('redstone/clock.litematic'),
schematicFile('redstone/contraptions/gear.schem'),
schematicFile('builds/castle.schem'),
]
const rows = buildInstanceSchematicRows(files, new Set(collectSchematicFolders(files)), '')
assert.deepEqual(
rows.map((row) =>
row.kind === 'folder' ? `folder:${row.name}(${row.fileCount})` : `file:${row.file.fileName}`,
),
[
'folder:builds(1)',
'file:castle.schem',
'file:house.litematic',
'folder:redstone(2)',
'file:clock.litematic',
'folder:contraptions(1)',
'file:gear.schem',
],
)
})
test('collapsed folders hide nested rows but keep their file count', () => {
const files = [
schematicFile('redstone/clock.litematic'),
schematicFile('redstone/contraptions/gear.schem'),
]
const rows = buildInstanceSchematicRows(files, new Set(), '')
assert.deepEqual(
rows.map((row) =>
row.kind === 'folder' ? `folder:${row.name}(${row.fileCount})` : `file:${row.file.fileName}`,
),
['folder:redstone(2)'],
)
})
test('nested rows track their depth and parent folder', () => {
const files = [schematicFile('a/b/c/tower.litematic')]
const rows = buildInstanceSchematicRows(files, new Set(collectSchematicFolders(files)), '')
const folders = folderRows(rows)
assert.deepEqual(
folders.map((row) => [row.path, row.depth]),
[
['a', 0],
['a/b', 1],
['a/b/c', 2],
],
)
const [file] = fileRows(rows)
assert.equal(file.file.fileName, 'tower.litematic')
assert.equal(file.depth, 3)
assert.equal(file.parentPath, 'a/b/c')
})
test('search flattens matching files and filters by relative path', () => {
const files = [
schematicFile('house.litematic'),
schematicFile('redstone/clock.litematic'),
schematicFile('builds/house.schem'),
]
const rows = buildInstanceSchematicRows(files, new Set(), 'house')
assert.deepEqual(
rows.map((row) => row.kind === 'file' && row.file.relativePath),
['house.litematic', 'builds/house.schem'],
)
assert.equal(folderRows(rows).length, 0)
})
test('windows-style backslash paths are normalized into folders', () => {
const files = [schematicFile('redstone\\clock.litematic')]
const folders = collectSchematicFolders(files)
assert.deepEqual(folders, ['redstone'])
const rows = buildInstanceSchematicRows(files, new Set(folders), '')
assert.deepEqual(
folderRows(rows).map((row) => [row.path, row.name, row.depth]),
[['redstone', 'redstone', 0]],
)
assert.equal(fileRows(rows)[0].file.fileName, 'clock.litematic')
})
test('folder paths are sorted and deduplicated', () => {
const files = [
schematicFile('b/x.litematic'),
schematicFile('a/y.schem'),
schematicFile('a/b/z.litematic'),
schematicFile('b/x.litematic'),
]
assert.deepEqual(collectSchematicFolders(files), ['a', 'a/b', 'b'])
})

View File

@ -0,0 +1,26 @@
import {
buildFileTreeRows,
collectFileTreeFolders,
type FileTreeFileRow,
type FileTreeFolderRow,
type FileTreeRow,
} from '@modrinth/ui/src/utils/file-tree.ts'
import type { InstanceSchematicFile } from './backend'
export type InstanceSchematicFolderRow = FileTreeFolderRow
export type InstanceSchematicFileRow = FileTreeFileRow<InstanceSchematicFile>
export type InstanceSchematicRow = FileTreeRow<InstanceSchematicFile>
export function collectSchematicFolders(files: readonly InstanceSchematicFile[]): string[] {
return collectFileTreeFolders(files)
}
export function buildInstanceSchematicRows(
files: readonly InstanceSchematicFile[],
expandedFolders: ReadonlySet<string>,
searchQuery: string,
locale = 'en',
): InstanceSchematicRow[] {
return buildFileTreeRows(files, expandedFolders, searchQuery, locale)
}

View File

@ -0,0 +1,438 @@
import * as deepslate from 'deepslate'
import type { SchematicBlockState } from './backend'
import { isSchematicAir } from './editing'
import {
applySeamlessSchematicGlassUvs,
getSchematicMeshOcclusionFaces,
getSchematicSpecialBlockMesh,
isSchematicOccluding,
isSchematicTranslucent,
SCHEMATIC_DIRECTIONS,
SCHEMATIC_OPPOSITE_DIRECTIONS,
schematicBlockAt,
type SchematicDirection,
type SchematicNeighborFaces,
type SchematicOcclusionFaces,
shouldCullSchematicFace,
} from './meshing'
import type { SchematicWorkerResources } from './resources'
type WorkerInitMessage = {
type: 'init'
epoch: number
palette: SchematicBlockState[]
resources: SchematicWorkerResources
seamlessGlass: boolean
}
type WorkerMeshMessage = {
type: 'mesh'
epoch: number
jobId: string
regionId: string
chunkPosition: [number, number, number]
blocks: ArrayBuffer
neighborFaces?: Partial<Record<SchematicDirection, ArrayBuffer>>
}
export type SchematicMeshWorkerRequest = WorkerInitMessage | WorkerMeshMessage
export type SchematicMeshData = {
positions: Float32Array
normals: Float32Array
uvs: Float32Array
colors: Float32Array
blockPositions: Float32Array
}
export type SchematicMeshWorkerResponse =
| { type: 'ready'; epoch: number; warnings: string[] }
| {
type: 'mesh'
epoch: number
jobId: string
regionId: string
chunkPosition: [number, number, number]
opaque: SchematicMeshData
translucent: SchematicMeshData
missing: string[]
}
| { type: 'error'; epoch: number; jobId?: string; message: string }
type MeshBuffers = {
positions: number[]
normals: number[]
uvs: number[]
colors: number[]
blockPositions: number[]
}
let activeEpoch = 0
let palette: SchematicBlockState[] = []
let blockDefinitions: Record<string, deepslate.BlockDefinition> = {}
let blockModels: Record<string, deepslate.BlockModel> = {}
let defaultBlockProperties: Record<string, Record<string, string>> = {}
let textureUvs: Record<string, [number, number, number, number]> = {}
let missingTextureUv: [number, number, number, number] = [0, 0, 1, 1]
let seamlessGlass = true
let paletteOcclusionFaces: SchematicOcclusionFaces[] = []
const workerScope = self as DedicatedWorkerGlobalScope
const atlasProvider: deepslate.TextureAtlasProvider = {
getTextureAtlas: () => ({}) as ImageData,
getTextureUV: (id) => textureUvs[id.toString()] ?? missingTextureUv,
}
const modelProvider = {
getBlockModel(id: deepslate.Identifier) {
return blockModels[id.toString()] ?? null
},
}
function resolvedBlockProperties(state: SchematicBlockState) {
return {
...(defaultBlockProperties[state.name] ?? {}),
...state.properties,
}
}
function createSchematicBlockMesh(state: SchematicBlockState, cull: deepslate.Cull) {
const properties = resolvedBlockProperties(state)
const mesh = getSchematicSpecialBlockMesh({ ...state, properties }, atlasProvider, cull)
const definition = blockDefinitions[state.name]
if (definition) {
mesh.merge(
definition.getMesh(
deepslate.Identifier.parse(state.name),
properties,
atlasProvider,
modelProvider,
cull,
),
)
}
return mesh
}
function emptyBuffers(): MeshBuffers {
return { positions: [], normals: [], uvs: [], colors: [], blockPositions: [] }
}
function toMeshData(buffers: MeshBuffers): SchematicMeshData {
return {
positions: new Float32Array(buffers.positions),
normals: new Float32Array(buffers.normals),
uvs: new Float32Array(buffers.uvs),
colors: new Float32Array(buffers.colors),
blockPositions: new Float32Array(buffers.blockPositions),
}
}
function transferables(data: SchematicMeshData): Transferable[] {
return [
data.positions.buffer,
data.normals.buffer,
data.uvs.buffer,
data.colors.buffer,
data.blockPositions.buffer,
]
}
function appendQuad(
buffers: MeshBuffers,
vertices: Array<{
pos: { x: number; y: number; z: number }
texture?: [number, number]
color: [number, number, number]
}>,
blockPosition: [number, number, number],
) {
const edge1 = {
x: vertices[1].pos.x - vertices[0].pos.x,
y: vertices[1].pos.y - vertices[0].pos.y,
z: vertices[1].pos.z - vertices[0].pos.z,
}
const edge2 = {
x: vertices[2].pos.x - vertices[0].pos.x,
y: vertices[2].pos.y - vertices[0].pos.y,
z: vertices[2].pos.z - vertices[0].pos.z,
}
const normal = {
x: edge1.y * edge2.z - edge1.z * edge2.y,
y: edge1.z * edge2.x - edge1.x * edge2.z,
z: edge1.x * edge2.y - edge1.y * edge2.x,
}
const length = Math.hypot(normal.x, normal.y, normal.z) || 1
const order = [0, 1, 2, 0, 2, 3]
for (const index of order) {
const vertex = vertices[index]
buffers.positions.push(vertex.pos.x, vertex.pos.y, vertex.pos.z)
buffers.normals.push(normal.x / length, normal.y / length, normal.z / length)
buffers.uvs.push(vertex.texture?.[0] ?? 0, vertex.texture?.[1] ?? 0)
buffers.colors.push(vertex.color[0], vertex.color[1], vertex.color[2])
buffers.blockPositions.push(...blockPosition)
}
}
function appendFallbackCube(
buffers: MeshBuffers,
position: [number, number, number],
cull: ReturnType<typeof deepslate.Cull.none>,
blockName: string,
) {
const [x, y, z] = position
const [u0, v0, u1, v1] = missingTextureUv
let hash = 0
for (const character of blockName) hash = (hash * 31 + character.charCodeAt(0)) | 0
const color: [number, number, number] = [
0.55 + ((hash >>> 0) & 0xff) / 640,
0.55 + ((hash >>> 8) & 0xff) / 640,
0.55 + ((hash >>> 16) & 0xff) / 640,
]
const vertex = (px: number, py: number, pz: number, u: number, v: number) => ({
pos: { x: px, y: py, z: pz },
texture: [u, v] as [number, number],
color,
})
const faces: Array<[keyof typeof cull, ReturnType<typeof vertex>[]]> = [
[
'up',
[
vertex(x, y + 1, z + 1, u0, v1),
vertex(x + 1, y + 1, z + 1, u1, v1),
vertex(x + 1, y + 1, z, u1, v0),
vertex(x, y + 1, z, u0, v0),
],
],
[
'down',
[
vertex(x, y, z, u0, v0),
vertex(x + 1, y, z, u1, v0),
vertex(x + 1, y, z + 1, u1, v1),
vertex(x, y, z + 1, u0, v1),
],
],
[
'south',
[
vertex(x, y, z + 1, u0, v1),
vertex(x + 1, y, z + 1, u1, v1),
vertex(x + 1, y + 1, z + 1, u1, v0),
vertex(x, y + 1, z + 1, u0, v0),
],
],
[
'north',
[
vertex(x + 1, y, z, u0, v1),
vertex(x, y, z, u1, v1),
vertex(x, y + 1, z, u1, v0),
vertex(x + 1, y + 1, z, u0, v0),
],
],
[
'east',
[
vertex(x + 1, y, z + 1, u0, v1),
vertex(x + 1, y, z, u1, v1),
vertex(x + 1, y + 1, z, u1, v0),
vertex(x + 1, y + 1, z + 1, u0, v0),
],
],
[
'west',
[
vertex(x, y, z, u0, v1),
vertex(x, y, z + 1, u1, v1),
vertex(x, y + 1, z + 1, u1, v0),
vertex(x, y + 1, z, u0, v0),
],
],
]
for (const [direction, vertices] of faces) {
if (!cull[direction]) appendQuad(buffers, vertices, position)
}
}
function initialize(message: WorkerInitMessage) {
activeEpoch = message.epoch
palette = message.palette
defaultBlockProperties = message.resources.defaultBlockProperties
textureUvs = message.resources.textureUvs
missingTextureUv = message.resources.missingTextureUv
seamlessGlass = message.seamlessGlass
const warnings: string[] = []
blockDefinitions = {}
for (const [id, value] of Object.entries(message.resources.blockDefinitions)) {
try {
blockDefinitions[id] = deepslate.BlockDefinition.fromJson(value)
} catch {
warnings.push(`Skipped invalid blockstate ${id}`)
}
}
blockModels = {}
for (const [id, value] of Object.entries(message.resources.blockModels)) {
try {
blockModels[id] = deepslate.BlockModel.fromJson(value)
} catch {
warnings.push(`Skipped invalid block model ${id}`)
}
}
for (const [id, model] of Object.entries(blockModels)) {
try {
model.flatten(modelProvider)
} catch {
Reflect.deleteProperty(blockModels, id)
warnings.push(`Skipped unresolved block model ${id}`)
}
}
paletteOcclusionFaces = palette.map((state, paletteIndex) => {
if (!isSchematicOccluding(paletteIndex, palette)) return {}
try {
return getSchematicMeshOcclusionFaces(createSchematicBlockMesh(state, deepslate.Cull.none()))
} catch {
return {}
}
})
workerScope.postMessage({
type: 'ready',
epoch: activeEpoch,
warnings,
} satisfies SchematicMeshWorkerResponse)
}
function buildChunk(message: WorkerMeshMessage) {
if (message.epoch !== activeEpoch) return
const blocks = new Uint32Array(message.blocks)
const neighborFaces: SchematicNeighborFaces = {}
for (const direction of SCHEMATIC_DIRECTIONS) {
const face = message.neighborFaces?.[direction]
if (face) neighborFaces[direction] = new Uint32Array(face)
}
const opaque = emptyBuffers()
const translucent = emptyBuffers()
const missing = new Set<string>()
const chunkOrigin = message.chunkPosition.map((value) => value * 16) as [number, number, number]
const shouldCull = (
currentPaletteIndex: number,
neighborPaletteIndex: number,
direction: SchematicDirection,
) =>
shouldCullSchematicFace(
currentPaletteIndex,
neighborPaletteIndex,
palette,
seamlessGlass,
paletteOcclusionFaces[neighborPaletteIndex]?.[SCHEMATIC_OPPOSITE_DIRECTIONS[direction]] ??
false,
)
for (let y = 0; y < 16; y += 1) {
for (let z = 0; z < 16; z += 1) {
for (let x = 0; x < 16; x += 1) {
const paletteIndex = schematicBlockAt(blocks, neighborFaces, x, y, z)
const state = palette[paletteIndex]
if (!state || isSchematicAir(state.name)) continue
const position: [number, number, number] = [
chunkOrigin[0] + x,
chunkOrigin[1] + y,
chunkOrigin[2] + z,
]
const cull = {
west: shouldCull(
paletteIndex,
schematicBlockAt(blocks, neighborFaces, x - 1, y, z),
'west',
),
east: shouldCull(
paletteIndex,
schematicBlockAt(blocks, neighborFaces, x + 1, y, z),
'east',
),
down: shouldCull(
paletteIndex,
schematicBlockAt(blocks, neighborFaces, x, y - 1, z),
'down',
),
up: shouldCull(paletteIndex, schematicBlockAt(blocks, neighborFaces, x, y + 1, z), 'up'),
north: shouldCull(
paletteIndex,
schematicBlockAt(blocks, neighborFaces, x, y, z - 1),
'north',
),
south: shouldCull(
paletteIndex,
schematicBlockAt(blocks, neighborFaces, x, y, z + 1),
'south',
),
}
const target = isSchematicTranslucent(state.name) ? translucent : opaque
try {
const mesh = createSchematicBlockMesh(state, cull)
if (mesh.quads.length === 0) {
missing.add(state.name)
appendFallbackCube(target, position, cull, state.name)
continue
}
for (const quad of mesh.quads) {
const vertices = quad.vertices().map((item) => ({
pos: {
x: item.pos.x + position[0],
y: item.pos.y + position[1],
z: item.pos.z + position[2],
},
texture: item.texture ? ([...item.texture] as [number, number]) : undefined,
color: item.color,
}))
applySeamlessSchematicGlassUvs(
vertices,
[x, y, z],
paletteIndex,
blocks,
neighborFaces,
palette,
seamlessGlass,
)
appendQuad(target, vertices, position)
}
} catch {
missing.add(state.name)
appendFallbackCube(target, position, cull, state.name)
}
}
}
}
const response: SchematicMeshWorkerResponse = {
type: 'mesh',
epoch: message.epoch,
jobId: message.jobId,
regionId: message.regionId,
chunkPosition: message.chunkPosition,
opaque: toMeshData(opaque),
translucent: toMeshData(translucent),
missing: [...missing],
}
if (response.type !== 'mesh') return
workerScope.postMessage(response, [
...transferables(response.opaque),
...transferables(response.translucent),
])
}
workerScope.onmessage = (event: MessageEvent<SchematicMeshWorkerRequest>) => {
try {
if (event.data.type === 'init') initialize(event.data)
else buildChunk(event.data)
} catch (error) {
workerScope.postMessage({
type: 'error',
epoch: event.data.epoch,
jobId: event.data.type === 'mesh' ? event.data.jobId : undefined,
message: error instanceof Error ? error.message : String(error),
} satisfies SchematicMeshWorkerResponse)
}
}

View File

@ -0,0 +1,238 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { Cull, type Identifier, Mesh, Quad, Vector } from 'deepslate'
import {
applySeamlessSchematicGlassUvs,
extractSchematicNeighborFace,
getSchematicMeshOcclusionFaces,
getSchematicSpecialBlockMesh,
isSchematicOccluding,
isSeamlessSchematicGlassPair,
schematicBlockAt,
shouldCullSchematicFace,
} from './meshing.ts'
const palette = [
{ name: 'minecraft:air', properties: {} },
{ name: 'minecraft:white_stained_glass', properties: {} },
{ name: 'minecraft:black_stained_glass', properties: {} },
{ name: 'minecraft:stone', properties: {} },
{ name: 'minecraft:sugar_cane', properties: {} },
{ name: 'minecraft:chest', properties: { facing: 'north', type: 'single' } },
{ name: 'minecraft:observer', properties: { facing: 'north', powered: 'false' } },
]
test('seamless glass only removes faces shared by the same full glass block', () => {
assert.equal(isSeamlessSchematicGlassPair(palette[1].name, palette[1].name), true)
assert.equal(isSeamlessSchematicGlassPair(palette[1].name, palette[2].name), false)
assert.equal(isSeamlessSchematicGlassPair('minecraft:glass', 'minecraft:glass_pane'), false)
assert.equal(shouldCullSchematicFace(1, 1, palette), true)
assert.equal(shouldCullSchematicFace(1, 1, palette, false), false)
assert.equal(shouldCullSchematicFace(1, 2, palette), false)
assert.equal(shouldCullSchematicFace(1, 3, palette), true)
assert.equal(shouldCullSchematicFace(1, 3, palette, false), true)
assert.equal(isSchematicOccluding(4, palette), false)
assert.equal(isSchematicOccluding(5, palette), false)
assert.equal(isSchematicOccluding(6, palette), false)
})
function addNorthFace(mesh: Mesh, x0: number, y0: number, x1: number, y1: number) {
mesh.quads.push(
Quad.fromPoints(
new Vector(x1, y0, 0),
new Vector(x0, y0, 0),
new Vector(x0, y1, 0),
new Vector(x1, y1, 0),
),
)
}
test('mesh occlusion requires complete coverage of each individual boundary face', () => {
const partial = new Mesh()
addNorthFace(partial, 0, 0, 1, 0.5)
assert.deepEqual(getSchematicMeshOcclusionFaces(partial), {})
const tiled = new Mesh()
addNorthFace(tiled, 0, 0, 1, 0.5)
addNorthFace(tiled, 0, 0.5, 1, 1)
assert.deepEqual(getSchematicMeshOcclusionFaces(tiled), { north: true })
})
test('neighbor faces preserve seamless glass culling across chunk boundaries', () => {
const current = new Uint32Array(4096)
const eastNeighbor = new Uint32Array(4096)
current[15] = 1
eastNeighbor[0] = 1
const east = extractSchematicNeighborFace(eastNeighbor, 'east')
const neighbor = schematicBlockAt(current, { east }, 16, 0, 0)
assert.equal(neighbor, 1)
assert.equal(shouldCullSchematicFace(current[15], neighbor, palette), true)
eastNeighbor[0] = 2
const differentGlass = extractSchematicNeighborFace(eastNeighbor, 'east')
assert.equal(schematicBlockAt(current, { east: differentGlass }, 16, 0, 0), 2)
assert.equal(shouldCullSchematicFace(1, 2, palette), false)
})
test('connected glass crops only the joined texture borders', () => {
const blocks = new Uint32Array(4096)
blocks[0] = 1
blocks[1] = 1
const vertices = [
{ pos: { x: 0, y: 0, z: 0 }, texture: [0, 1] as [number, number] },
{ pos: { x: 1, y: 0, z: 0 }, texture: [1, 1] as [number, number] },
{ pos: { x: 1, y: 1, z: 0 }, texture: [1, 0] as [number, number] },
{ pos: { x: 0, y: 1, z: 0 }, texture: [0, 0] as [number, number] },
]
applySeamlessSchematicGlassUvs(vertices, [0, 0, 0], 1, blocks, {}, palette)
assert.deepEqual(
vertices.map((vertex) => vertex.texture),
[
[0, 1],
[15 / 16, 1],
[15 / 16, 0],
[0, 0],
],
)
})
test('disabled seamless glass preserves the original texture coordinates', () => {
const blocks = new Uint32Array(4096)
blocks[0] = 1
blocks[1] = 1
const vertices = [
{ pos: { x: 0, y: 0, z: 0 }, texture: [0, 1] as [number, number] },
{ pos: { x: 1, y: 0, z: 0 }, texture: [1, 1] as [number, number] },
{ pos: { x: 1, y: 1, z: 0 }, texture: [1, 0] as [number, number] },
{ pos: { x: 0, y: 1, z: 0 }, texture: [0, 0] as [number, number] },
]
applySeamlessSchematicGlassUvs(vertices, [0, 0, 0], 1, blocks, {}, palette, false)
assert.deepEqual(
vertices.map((vertex) => vertex.texture),
[
[0, 1],
[1, 1],
[1, 0],
[0, 0],
],
)
})
test('chests use Minecraft special geometry and entity textures', () => {
for (const [name, texture] of [
['minecraft:chest', 'minecraft:entity/chest/normal'],
['minecraft:trapped_chest', 'minecraft:entity/chest/trapped'],
['minecraft:ender_chest', 'minecraft:entity/chest/ender'],
]) {
const requestedTextures = new Set<string>()
const mesh = getSchematicSpecialBlockMesh(
{ name, properties: { facing: 'east', type: 'single', waterlogged: 'false' } },
{
getTextureAtlas: () => ({}) as ImageData,
getTextureUV: (id: Identifier) => {
requestedTextures.add(id.toString())
return [0, 0, 1, 1]
},
},
Cull.none(),
)
assert.ok(mesh.quads.length > 0, `${name} generated no special mesh`)
assert.ok(requestedTextures.has(texture), `${name} did not request ${texture}`)
}
})
test('double chests use the matching left and right entity textures', () => {
for (const [name, texture] of [
['minecraft:chest', 'normal'],
['minecraft:trapped_chest', 'trapped'],
['minecraft:copper_chest', 'copper'],
['minecraft:exposed_copper_chest', 'copper_exposed'],
['minecraft:weathered_copper_chest', 'copper_weathered'],
['minecraft:oxidized_copper_chest', 'copper_oxidized'],
['minecraft:waxed_copper_chest', 'copper'],
['minecraft:waxed_exposed_copper_chest', 'copper_exposed'],
['minecraft:waxed_weathered_copper_chest', 'copper_weathered'],
['minecraft:waxed_oxidized_copper_chest', 'copper_oxidized'],
]) {
for (const type of ['left', 'right']) {
const requestedTextures = new Set<string>()
const mesh = getSchematicSpecialBlockMesh(
{ name, properties: { facing: 'north', type, waterlogged: 'false' } },
{
getTextureAtlas: () => ({}) as ImageData,
getTextureUV: (id: Identifier) => {
requestedTextures.add(id.toString())
return [0, 0, 1, 1]
},
},
Cull.none(),
)
assert.ok(mesh.quads.length > 0, `${name}[type=${type}] generated no mesh`)
assert.deepEqual(requestedTextures, new Set([`minecraft:entity/chest/${texture}_${type}`]))
}
}
})
test('double chest halves meet across every facing direction', () => {
const atlas = {
getTextureAtlas: () => ({}) as ImageData,
getTextureUV: () => [0, 0, 1, 1] as [number, number, number, number],
}
const facings = [
{ facing: 'north', axis: 'x', offset: [1, 0, 0] as const },
{ facing: 'south', axis: 'x', offset: [-1, 0, 0] as const },
{ facing: 'west', axis: 'z', offset: [0, 0, -1] as const },
{ facing: 'east', axis: 'z', offset: [0, 0, 1] as const },
]
const coordinate = (mesh: ReturnType<typeof getSchematicSpecialBlockMesh>, axis: 'x' | 'z') =>
mesh.quads.flatMap((quad) => quad.vertices().map((vertex) => vertex.pos[axis]))
for (const { facing, axis, offset } of facings) {
const left = getSchematicSpecialBlockMesh(
{ name: 'minecraft:chest', properties: { facing, type: 'left' } },
atlas,
Cull.none(),
)
const right = getSchematicSpecialBlockMesh(
{ name: 'minecraft:chest', properties: { facing, type: 'right' } },
atlas,
Cull.none(),
)
const leftCoordinates = coordinate(left, axis)
const rightCoordinates = coordinate(right, axis).map(
(value) => value + (axis === 'x' ? offset[0] : offset[2]),
)
const positive = (axis === 'x' ? offset[0] : offset[2]) > 0
const leftEdge = positive ? Math.max(...leftCoordinates) : Math.min(...leftCoordinates)
const rightEdge = positive ? Math.min(...rightCoordinates) : Math.max(...rightCoordinates)
const combined = [...leftCoordinates, ...rightCoordinates]
assert.ok(Math.abs(leftEdge - rightEdge) < 1e-6, `${facing} chest halves have a gap`)
assert.ok(
Math.abs(Math.max(...combined) - Math.min(...combined) - 30 / 16) < 1e-6,
`${facing} double chest is not 30 pixels wide`,
)
}
})
test('ender chests remain single even with an invalid double-chest type', () => {
const requestedTextures = new Set<string>()
getSchematicSpecialBlockMesh(
{ name: 'minecraft:ender_chest', properties: { facing: 'north', type: 'left' } },
{
getTextureAtlas: () => ({}) as ImageData,
getTextureUV: (id: Identifier) => {
requestedTextures.add(id.toString())
return [0, 0, 1, 1]
},
},
Cull.none(),
)
assert.ok(requestedTextures.has('minecraft:entity/chest/ender'))
assert.ok(!requestedTextures.has('minecraft:entity/chest/ender_left'))
})

View File

@ -0,0 +1,539 @@
import {
BlockState,
type Cull,
Identifier,
Mesh,
Quad,
SpecialRenderers,
type TextureAtlasProvider,
Vector,
} from 'deepslate'
import type { SchematicBlockState } from './backend.ts'
import { isSchematicAir } from './editing.ts'
export const SCHEMATIC_DIRECTIONS = ['west', 'east', 'down', 'up', 'north', 'south'] as const
export type SchematicDirection = (typeof SCHEMATIC_DIRECTIONS)[number]
export type SchematicNeighborFaces = Partial<Record<SchematicDirection, Uint32Array>>
export type SchematicOcclusionFaces = Partial<Record<SchematicDirection, true>>
export type SchematicTexturedVertex = {
pos: { x: number; y: number; z: number }
texture?: [number, number]
}
export const SCHEMATIC_OPPOSITE_DIRECTIONS: Record<SchematicDirection, SchematicDirection> = {
west: 'east',
east: 'west',
down: 'up',
up: 'down',
north: 'south',
south: 'north',
}
export const SCHEMATIC_DIRECTION_OFFSETS: Record<
SchematicDirection,
readonly [number, number, number]
> = {
west: [-1, 0, 0],
east: [1, 0, 0],
down: [0, -1, 0],
up: [0, 1, 0],
north: [0, 0, -1],
south: [0, 0, 1],
}
const TRANSLUCENT_BLOCK_PARTS = [
'glass',
'ice',
'water',
'lava',
'slime_block',
'honey_block',
'nether_portal',
'end_gateway',
]
const NON_OCCLUDING_BLOCK_PARTS = [
'observer',
'leaves',
'pane',
'fence',
'wall',
'door',
'trapdoor',
'slab',
'stairs',
'plant',
'flower',
'sapling',
'grass',
'fern',
'bamboo',
'sugar_cane',
'cactus',
'kelp',
'seagrass',
'vine',
'torch',
'rail',
'button',
'pressure_plate',
'carpet',
'candle',
'chain',
'rod',
'mushroom',
'fungus',
'roots',
'crops',
'wheat',
'carrots',
'potatoes',
'beetroots',
'stem',
'berry',
'lichen',
'ladder',
'scaffolding',
'iron_bars',
'coral',
'sea_pickle',
'frogspawn',
'campfire',
'brewing_stand',
'grindstone',
'chest',
]
const SCHEMATIC_OCCLUSION_PLANES: Record<
SchematicDirection,
{
axis: 'x' | 'y' | 'z'
boundary: number
projection: readonly ['x' | 'y' | 'z', 'x' | 'y' | 'z']
}
> = {
west: { axis: 'x', boundary: 0, projection: ['y', 'z'] },
east: { axis: 'x', boundary: 1, projection: ['y', 'z'] },
down: { axis: 'y', boundary: 0, projection: ['x', 'z'] },
up: { axis: 'y', boundary: 1, projection: ['x', 'z'] },
north: { axis: 'z', boundary: 0, projection: ['x', 'y'] },
south: { axis: 'z', boundary: 1, projection: ['x', 'y'] },
}
const OCCLUSION_GRID_SIZE = 16
const OCCLUSION_EPSILON = 1e-5
type SchematicPoint2d = readonly [number, number]
function pointInTriangle(
point: SchematicPoint2d,
first: SchematicPoint2d,
second: SchematicPoint2d,
third: SchematicPoint2d,
) {
const area =
(second[0] - first[0]) * (third[1] - first[1]) - (second[1] - first[1]) * (third[0] - first[0])
if (Math.abs(area) <= OCCLUSION_EPSILON) return false
const edge = (start: SchematicPoint2d, end: SchematicPoint2d) =>
(point[0] - end[0]) * (start[1] - end[1]) - (start[0] - end[0]) * (point[1] - end[1])
const firstEdge = edge(first, second)
const secondEdge = edge(second, third)
const thirdEdge = edge(third, first)
const hasNegative =
firstEdge < -OCCLUSION_EPSILON ||
secondEdge < -OCCLUSION_EPSILON ||
thirdEdge < -OCCLUSION_EPSILON
const hasPositive =
firstEdge > OCCLUSION_EPSILON || secondEdge > OCCLUSION_EPSILON || thirdEdge > OCCLUSION_EPSILON
return !(hasNegative && hasPositive)
}
export function getSchematicMeshOcclusionFaces(mesh: Mesh): SchematicOcclusionFaces {
const coverage = Object.fromEntries(
SCHEMATIC_DIRECTIONS.map((direction) => [direction, new Uint8Array(256)]),
) as Record<SchematicDirection, Uint8Array>
for (const quad of mesh.quads) {
const vertices = quad.vertices()
for (const direction of SCHEMATIC_DIRECTIONS) {
const plane = SCHEMATIC_OCCLUSION_PLANES[direction]
if (
!vertices.every(
(vertex) => Math.abs(vertex.pos[plane.axis] - plane.boundary) <= OCCLUSION_EPSILON,
)
) {
continue
}
const points = vertices.map(
(vertex) =>
[vertex.pos[plane.projection[0]], vertex.pos[plane.projection[1]]] as SchematicPoint2d,
)
for (let row = 0; row < OCCLUSION_GRID_SIZE; row += 1) {
for (let column = 0; column < OCCLUSION_GRID_SIZE; column += 1) {
const point: SchematicPoint2d = [
(column + 0.5) / OCCLUSION_GRID_SIZE,
(row + 0.5) / OCCLUSION_GRID_SIZE,
]
if (
pointInTriangle(point, points[0], points[1], points[2]) ||
pointInTriangle(point, points[0], points[2], points[3])
) {
coverage[direction][row * OCCLUSION_GRID_SIZE + column] = 1
}
}
}
}
}
return Object.fromEntries(
SCHEMATIC_DIRECTIONS.filter((direction) => coverage[direction].every(Boolean)).map(
(direction) => [direction, true],
),
) as SchematicOcclusionFaces
}
const CHEST_TEXTURES: Record<string, string> = {
'minecraft:chest': 'normal',
'minecraft:trapped_chest': 'trapped',
'minecraft:ender_chest': 'ender',
'minecraft:copper_chest': 'copper',
'minecraft:exposed_copper_chest': 'copper_exposed',
'minecraft:weathered_copper_chest': 'copper_weathered',
'minecraft:oxidized_copper_chest': 'copper_oxidized',
'minecraft:waxed_copper_chest': 'copper',
'minecraft:waxed_exposed_copper_chest': 'copper_exposed',
'minecraft:waxed_weathered_copper_chest': 'copper_weathered',
'minecraft:waxed_oxidized_copper_chest': 'copper_oxidized',
}
type ChestHalf = 'left' | 'right'
type ChestPosition = readonly [number, number, number]
type ChestTextureUv = readonly [number, number, number, number]
function addChestFace(
mesh: Mesh,
positions: readonly [ChestPosition, ChestPosition, ChestPosition, ChestPosition],
atlasUv: ChestTextureUv,
textureUv: ChestTextureUv,
) {
const [atlasU0, atlasV0, atlasU1, atlasV1] = atlasUv
const atlasU = (pixel: number) => atlasU0 + (pixel / 64) * (atlasU1 - atlasU0)
const atlasV = (pixel: number) => atlasV0 + (pixel / 64) * (atlasV1 - atlasV0)
const [textureU0, textureV0, textureU1, textureV1] = textureUv
const mappedUv = [
atlasU(textureU1),
atlasV(textureV0),
atlasU(textureU0),
atlasV(textureV0),
atlasU(textureU0),
atlasV(textureV1),
atlasU(textureU1),
atlasV(textureV1),
]
const textureLimit: [number, number, number, number] = [
Math.min(mappedUv[0], mappedUv[2]),
Math.min(mappedUv[1], mappedUv[5]),
Math.max(mappedUv[0], mappedUv[2]),
Math.max(mappedUv[1], mappedUv[5]),
]
const quad = Quad.fromPoints(
new Vector(...positions[0]),
new Vector(...positions[1]),
new Vector(...positions[2]),
new Vector(...positions[3]),
)
quad.setColor([1, 1, 1]).setTexture(mappedUv, textureLimit)
mesh.quads.push(quad)
}
function addChestCube(
mesh: Mesh,
atlasUv: ChestTextureUv,
from: ChestPosition,
geometrySize: ChestPosition,
textureSize: ChestPosition,
textureOffset: readonly [number, number],
sideCrop = 0,
) {
const [x0, y0, z0] = from
const [geometryWidth, geometryHeight, geometryDepth] = geometrySize
const [textureWidth, textureHeight, textureDepth] = textureSize
const x1 = x0 + geometryWidth
const y1 = y0 + geometryHeight
const z1 = z0 + geometryDepth
const p000 = [x0, y0, z0] as const
const p100 = [x1, y0, z0] as const
const p110 = [x1, y1, z0] as const
const p010 = [x0, y1, z0] as const
const p001 = [x0, y0, z1] as const
const p101 = [x1, y0, z1] as const
const p111 = [x1, y1, z1] as const
const p011 = [x0, y1, z1] as const
const [textureU, textureV] = textureOffset
const u0 = textureU
const u1 = u0 + textureDepth
const u2 = u1 + textureWidth
const u3 = u2 + textureWidth
const u4 = u2 + textureDepth
const u5 = u4 + textureWidth
const v0 = textureV
const v1 = v0 + textureDepth
const v2 = v1 + textureHeight
addChestFace(mesh, [p101, p001, p000, p100], atlasUv, [u1, v0, u2, v1])
addChestFace(mesh, [p110, p010, p011, p111], atlasUv, [u2, v1, u3, v0])
addChestFace(mesh, [p000, p001, p011, p010], atlasUv, [u0, v1 + sideCrop, u1, v2])
addChestFace(mesh, [p100, p000, p010, p110], atlasUv, [u1, v1 + sideCrop, u2, v2])
addChestFace(mesh, [p101, p100, p110, p111], atlasUv, [u2, v1 + sideCrop, u4, v2])
addChestFace(mesh, [p001, p101, p111, p011], atlasUv, [u4, v1 + sideCrop, u5, v2])
}
function chestTransform(facing: string) {
const [cosine, sine] =
facing === 'east' ? [0, 1] : facing === 'north' ? [-1, 0] : facing === 'west' ? [0, -1] : [1, 0]
const scale = 1 / 16
const translateX = 8 - 8 * cosine - 8 * sine
const translateZ = 8 + 8 * sine - 8 * cosine
return new Float32Array([
cosine * scale,
0,
-sine * scale,
0,
0,
scale,
0,
0,
sine * scale,
0,
cosine * scale,
0,
translateX * scale,
0,
translateZ * scale,
1,
])
}
function getDoubleChestMesh(
state: SchematicBlockState,
texture: string,
half: ChestHalf,
atlas: TextureAtlasProvider,
) {
const atlasUv = atlas.getTextureUV(Identifier.parse(`minecraft:entity/chest/${texture}_${half}`))
const bodyFromX = half === 'left' ? 0 : 1
const lockFromX = half === 'left' ? 0 : 15
const mesh = new Mesh()
addChestCube(mesh, atlasUv, [bodyFromX, 0, 1], [15, 10, 14], [15, 10, 14], [0, 19])
addChestCube(mesh, atlasUv, [bodyFromX, 10, 1], [15, 4, 14], [15, 5, 14], [0, 0], 1)
addChestCube(mesh, atlasUv, [lockFromX, 7, 15], [1, 4, 1], [1, 4, 1], [0, 0])
return mesh.transform(chestTransform(state.properties.facing ?? 'south'))
}
function normalizedBlockName(name: string) {
return name.trim().toLowerCase()
}
export function getSchematicSpecialBlockMesh(
state: SchematicBlockState,
atlas: TextureAtlasProvider,
cull: Cull,
) {
const normalizedName = normalizedBlockName(state.name)
const chestTexture = CHEST_TEXTURES[normalizedName]
const chestType = state.properties.type
if (
chestTexture !== undefined &&
normalizedName !== 'minecraft:ender_chest' &&
(chestType === 'left' || chestType === 'right')
) {
return getDoubleChestMesh(state, chestTexture, chestType, atlas)
}
return SpecialRenderers.getBlockMesh(
new BlockState(state.name, state.properties),
undefined,
atlas,
cull,
)
}
function seamlessGlassId(name: string) {
const normalized = normalizedBlockName(name)
const path = normalized.split(':').at(-1) ?? normalized
return path === 'glass' || path === 'tinted_glass' || path.endsWith('_stained_glass')
? normalized
: undefined
}
export function isSchematicTranslucent(name: string) {
const normalized = normalizedBlockName(name)
return TRANSLUCENT_BLOCK_PARTS.some((part) => normalized.includes(part))
}
export function isSeamlessSchematicGlassPair(currentName: string, neighborName: string) {
const current = seamlessGlassId(currentName)
return current !== undefined && current === seamlessGlassId(neighborName)
}
export function isSchematicOccluding(
paletteIndex: number,
palette: readonly SchematicBlockState[],
) {
const state = palette[paletteIndex]
return (
state !== undefined &&
!isSchematicAir(state.name) &&
!isSchematicTranslucent(state.name) &&
!NON_OCCLUDING_BLOCK_PARTS.some((part) => state.name.includes(part))
)
}
export function shouldCullSchematicFace(
currentPaletteIndex: number,
neighborPaletteIndex: number,
palette: readonly SchematicBlockState[],
seamlessGlass = true,
neighborOccludes = isSchematicOccluding(neighborPaletteIndex, palette),
) {
if (neighborOccludes) return true
if (!seamlessGlass) return false
const current = palette[currentPaletteIndex]
const neighbor = palette[neighborPaletteIndex]
return Boolean(current && neighbor && isSeamlessSchematicGlassPair(current.name, neighbor.name))
}
export function schematicNeighborChunkPosition(
position: readonly [number, number, number],
direction: SchematicDirection,
): [number, number, number] {
const offset = SCHEMATIC_DIRECTION_OFFSETS[direction]
return [position[0] + offset[0], position[1] + offset[1], position[2] + offset[2]]
}
export function extractSchematicNeighborFace(blocks: Uint32Array, direction: SchematicDirection) {
const face = new Uint32Array(256)
for (let first = 0; first < 16; first += 1) {
for (let second = 0; second < 16; second += 1) {
if (direction === 'west' || direction === 'east') {
const x = direction === 'west' ? 15 : 0
face[first * 16 + second] = blocks[first * 256 + second * 16 + x] ?? 0
} else if (direction === 'down' || direction === 'up') {
const y = direction === 'down' ? 15 : 0
face[first * 16 + second] = blocks[y * 256 + first * 16 + second] ?? 0
} else {
const z = direction === 'north' ? 15 : 0
face[first * 16 + second] = blocks[first * 256 + z * 16 + second] ?? 0
}
}
}
return face
}
export function schematicBlockAt(
blocks: Uint32Array,
neighborFaces: SchematicNeighborFaces,
x: number,
y: number,
z: number,
) {
if (x === -1 && y >= 0 && y < 16 && z >= 0 && z < 16) {
return neighborFaces.west?.[y * 16 + z] ?? 0
}
if (x === 16 && y >= 0 && y < 16 && z >= 0 && z < 16) {
return neighborFaces.east?.[y * 16 + z] ?? 0
}
if (y === -1 && x >= 0 && x < 16 && z >= 0 && z < 16) {
return neighborFaces.down?.[z * 16 + x] ?? 0
}
if (y === 16 && x >= 0 && x < 16 && z >= 0 && z < 16) {
return neighborFaces.up?.[z * 16 + x] ?? 0
}
if (z === -1 && x >= 0 && x < 16 && y >= 0 && y < 16) {
return neighborFaces.north?.[y * 16 + x] ?? 0
}
if (z === 16 && x >= 0 && x < 16 && y >= 0 && y < 16) {
return neighborFaces.south?.[y * 16 + x] ?? 0
}
if (x < 0 || x >= 16 || y < 0 || y >= 16 || z < 0 || z >= 16) return 0
return blocks[y * 256 + z * 16 + x] ?? 0
}
export function applySeamlessSchematicGlassUvs(
vertices: SchematicTexturedVertex[],
position: readonly [number, number, number],
currentPaletteIndex: number,
blocks: Uint32Array,
neighborFaces: SchematicNeighborFaces,
palette: readonly SchematicBlockState[],
seamlessGlass = true,
) {
if (!seamlessGlass) return
const current = palette[currentPaletteIndex]
if (!current || !seamlessGlassId(current.name) || vertices.length < 3) return
const textured = vertices.every((vertex) => vertex.texture !== undefined)
if (!textured) return
const edge1 = [
vertices[1].pos.x - vertices[0].pos.x,
vertices[1].pos.y - vertices[0].pos.y,
vertices[1].pos.z - vertices[0].pos.z,
]
const edge2 = [
vertices[2].pos.x - vertices[0].pos.x,
vertices[2].pos.y - vertices[0].pos.y,
vertices[2].pos.z - vertices[0].pos.z,
]
const normal = [
edge1[1] * edge2[2] - edge1[2] * edge2[1],
edge1[2] * edge2[0] - edge1[0] * edge2[2],
edge1[0] * edge2[1] - edge1[1] * edge2[0],
]
const normalAxis = normal.reduce(
(best, value, axis) => (Math.abs(value) > Math.abs(normal[best]) ? axis : best),
0,
)
const originalTextures = vertices.map((vertex) => [...vertex.texture!] as [number, number])
const coordinates = vertices.map((vertex) => [vertex.pos.x, vertex.pos.y, vertex.pos.z])
for (const direction of SCHEMATIC_DIRECTIONS) {
const offset = SCHEMATIC_DIRECTION_OFFSETS[direction]
const axis = offset.findIndex((value) => value !== 0)
if (axis === normalAxis) continue
const neighborIndex = schematicBlockAt(
blocks,
neighborFaces,
position[0] + offset[0],
position[1] + offset[1],
position[2] + offset[2],
)
const neighbor = palette[neighborIndex]
if (!neighbor || !isSeamlessSchematicGlassPair(current.name, neighbor.name)) continue
const axisCoordinates = coordinates.map((coordinate) => coordinate[axis])
const edgeCoordinate =
offset[axis] < 0 ? Math.min(...axisCoordinates) : Math.max(...axisCoordinates)
const edgeVertices = axisCoordinates
.map((coordinate, index) => ({ coordinate, index }))
.filter(({ coordinate }) => Math.abs(coordinate - edgeCoordinate) < 1e-5)
.map(({ index }) => index)
if (edgeVertices.length !== 2) continue
const innerVertices = vertices
.map((_, index) => index)
.filter((index) => !edgeVertices.includes(index))
for (const textureAxis of [0, 1] as const) {
const edgeValues = edgeVertices.map((index) => originalTextures[index][textureAxis])
if (Math.abs(edgeValues[0] - edgeValues[1]) >= 1e-7) continue
const innerValue =
innerVertices.reduce((sum, index) => sum + originalTextures[index][textureAxis], 0) /
innerVertices.length
const edgeValue = edgeValues[0]
const shift = (innerValue - edgeValue) / 16
if (Math.abs(shift) < 1e-7) continue
for (const index of edgeVertices) {
vertices[index].texture![textureAxis] = originalTextures[index][textureAxis] + shift
}
break
}
}
}

View File

@ -0,0 +1,87 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { CanvasTexture, LinearFilter, NearestFilter } from 'three'
import {
configureSchematicTexture,
resolveSchematicBlockName,
resolveSchematicMaterialTexture,
} from './resources.ts'
test('block names follow the current locale and fall back without exposing internal IDs', () => {
const names = {
en_us: {
'block.minecraft.stone': 'Stone',
'block.example.machine.frame': 'Machine Frame',
},
zh_cn: { 'block.minecraft.stone': '\u77f3\u5934' },
}
assert.equal(resolveSchematicBlockName('minecraft:stone', names, 'zh-CN'), '\u77f3\u5934')
assert.equal(resolveSchematicBlockName('example:machine/frame', names, 'zh-CN'), 'Machine Frame')
assert.equal(resolveSchematicBlockName('example:polished_tile', names, 'zh-CN'), 'Polished Tile')
})
test('schematic textures preserve Deepslate atlas row coordinates', () => {
const texture = new CanvasTexture({} as HTMLCanvasElement)
texture.flipY = true
texture.magFilter = LinearFilter
texture.minFilter = LinearFilter
texture.generateMipmaps = true
const previousVersion = texture.version
configureSchematicTexture(texture)
assert.equal(texture.flipY, false)
assert.equal(texture.magFilter, NearestFilter)
assert.equal(texture.minFilter, NearestFilter)
assert.equal(texture.generateMipmaps, false)
assert.ok(texture.version > previousVersion)
})
test('material previews resolve blockstate models and texture variables', () => {
const sideUv: [number, number, number, number] = [0.25, 0.5, 0.5, 0.75]
const resources = {
blockDefinitions: {
'minecraft:grass_block': {
variants: { 'snowy=false': [{ model: 'minecraft:block/grass_block' }] },
},
},
blockModels: {
'minecraft:block/grass_block': {
parent: 'minecraft:block/cube_bottom_top',
textures: {
side: '#side_texture',
side_texture: 'minecraft:block/grass_block_side',
top: 'minecraft:block/grass_block_top',
},
},
'minecraft:block/cube_bottom_top': {
textures: { particle: 'minecraft:block/dirt' },
},
},
defaultBlockProperties: {},
textureUvs: {
'minecraft:block/grass_block_side': sideUv,
'minecraft:block/grass_block_top': [0, 0, 0.25, 0.25] as [number, number, number, number],
'minecraft:block/dirt': [0.5, 0.5, 0.75, 0.75] as [number, number, number, number],
},
missingTextureUv: [0, 0, 0.1, 0.1] as [number, number, number, number],
}
assert.deepEqual(resolveSchematicMaterialTexture('minecraft:grass_block', resources), sideUv)
})
test('material previews fall back to conventional texture names', () => {
const uv: [number, number, number, number] = [0, 0.25, 0.25, 0.5]
const resources = {
blockDefinitions: {},
blockModels: {},
defaultBlockProperties: {},
textureUvs: { 'example:block/polished_tile': uv },
missingTextureUv: [0, 0, 0.1, 0.1] as [number, number, number, number],
}
assert.deepEqual(resolveSchematicMaterialTexture('example:polished_tile', resources), uv)
})

View File

@ -0,0 +1,238 @@
import { CanvasTexture, NearestFilter, SRGBColorSpace } from 'three'
import type { SchematicBlockState } from './backend.ts'
export type SchematicWorkerResources = {
blockDefinitions: Record<string, unknown>
blockModels: Record<string, unknown>
defaultBlockProperties: Record<string, Record<string, string>>
textureUvs: Record<string, [number, number, number, number]>
missingTextureUv: [number, number, number, number]
}
export type SchematicBlockNames = {
en_us: Record<string, string>
zh_cn: Record<string, string>
}
export type LoadedSchematicResources = {
workerResources: SchematicWorkerResources
previewResources: SchematicWorkerResources
blockNames: SchematicBlockNames
availableBlockStates: SchematicBlockState[]
texture: CanvasTexture
atlas: HTMLCanvasElement
}
type JsonObject = Record<string, unknown>
function asObject(value: unknown): JsonObject | undefined {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as JsonObject)
: undefined
}
function identifier(value: string) {
const normalized = value.trim().toLowerCase()
return normalized.includes(':') ? normalized : `minecraft:${normalized}`
}
function conventionalBlockTextureIds(blockId: string) {
const normalized = identifier(blockId)
const [namespace, path] = normalized.split(':', 2) as [string, string]
return [
`${namespace}:block/${path}`,
`${namespace}:block/${path}_side`,
`${namespace}:block/${path}_top`,
`${namespace}:block/${path}_front`,
`${namespace}:block/${path}_still`,
]
}
function blockTranslationKey(blockName: string) {
const normalizedName = blockName.trim().toLowerCase()
const [namespace, path] = normalizedName.includes(':')
? (normalizedName.split(':', 2) as [string, string])
: ['minecraft', normalizedName]
return `block.${namespace}.${path.replaceAll('/', '.')}`
}
function humanizeBlockName(blockName: string) {
const path = blockName.trim().split(':').at(-1) ?? blockName
return path
.split(/[_./-]+/)
.filter(Boolean)
.map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`)
.join(' ')
}
export function resolveSchematicBlockName(
blockName: string,
blockNames: SchematicBlockNames,
locale: string,
) {
const translationKey = blockTranslationKey(blockName)
const preferred = locale.toLowerCase().startsWith('zh') ? 'zh_cn' : 'en_us'
const fallback = preferred === 'zh_cn' ? 'en_us' : 'zh_cn'
return (
blockNames[preferred][translationKey] ??
blockNames[fallback][translationKey] ??
humanizeBlockName(blockName)
)
}
function firstModelReference(value: unknown): string | undefined {
if (Array.isArray(value)) {
for (const entry of value) {
const model = firstModelReference(entry)
if (model) return model
}
return undefined
}
const object = asObject(value)
return typeof object?.model === 'string' ? object.model : undefined
}
function blockModelReference(definition: unknown) {
const object = asObject(definition)
const variants = asObject(object?.variants)
if (variants) {
for (const variant of Object.values(variants)) {
const model = firstModelReference(variant)
if (model) return model
}
}
if (Array.isArray(object?.multipart)) {
for (const part of object.multipart) {
const model = firstModelReference(asObject(part)?.apply)
if (model) return model
}
}
return undefined
}
function modelTextures(
modelId: string,
resources: SchematicWorkerResources,
visited = new Set<string>(),
): Record<string, string> {
if (visited.has(modelId)) return {}
visited.add(modelId)
const model = asObject(resources.blockModels[modelId])
if (!model) return {}
const parent = typeof model.parent === 'string' ? identifier(model.parent) : undefined
const textures = parent ? modelTextures(parent, resources, visited) : {}
for (const [key, value] of Object.entries(asObject(model.textures) ?? {})) {
if (typeof value === 'string') textures[key] = value
}
return textures
}
function resolveTextureReference(value: string, textures: Record<string, string>) {
let current = value
const visited = new Set<string>()
while (current.startsWith('#')) {
const key = current.slice(1)
if (visited.has(key)) return undefined
visited.add(key)
const next = textures[key]
if (!next) return undefined
current = next
}
return identifier(current)
}
export function resolveSchematicMaterialTexture(
blockName: string,
resources: SchematicWorkerResources,
) {
const normalizedName = blockName.trim().toLowerCase()
const [namespace, path] = normalizedName.includes(':')
? (normalizedName.split(':', 2) as [string, string])
: ['minecraft', normalizedName]
const modelReference = blockModelReference(resources.blockDefinitions[normalizedName])
if (modelReference) {
const modelId = identifier(modelReference)
const textures = modelTextures(modelId, resources)
const preferredKeys = ['all', 'side', 'top', 'end', 'front', 'texture', 'particle']
for (const key of [...preferredKeys, ...Object.keys(textures)]) {
const value = textures[key]
if (!value) continue
const textureId = resolveTextureReference(value, textures)
if (textureId && resources.textureUvs[textureId]) return resources.textureUvs[textureId]
}
}
const candidates = conventionalBlockTextureIds(`${namespace}:${path}`)
for (const candidate of candidates) {
if (resources.textureUvs[candidate]) return resources.textureUvs[candidate]
}
return undefined
}
export function configureSchematicTexture(texture: CanvasTexture) {
// Deepslate UVs address atlas rows in the canvas's original top-to-bottom order.
texture.flipY = false
texture.magFilter = NearestFilter
texture.minFilter = NearestFilter
texture.generateMipmaps = false
texture.needsUpdate = true
return texture
}
export function minecraftVersionFromDataVersion(dataVersion?: number) {
if (dataVersion === undefined) return undefined
if (dataVersion >= 3953) return '1.21'
if (dataVersion >= 3463) return '1.20'
if (dataVersion >= 3105) return '1.19'
if (dataVersion >= 2860) return '1.18'
if (dataVersion >= 2724) return '1.17'
if (dataVersion >= 2566) return '1.16'
if (dataVersion >= 2200) return '1.15'
if (dataVersion >= 1901) return '1.14'
if (dataVersion >= 1451) return '1.13'
return undefined
}
export async function createSchematicResources(
version: string,
palette: readonly SchematicBlockState[],
): Promise<LoadedSchematicResources> {
const builtin = await import('./builtin-resources.ts')
const canvas = document.createElement('canvas')
canvas.width = builtin.BUILTIN_ATLAS_WIDTH
canvas.height = builtin.BUILTIN_ATLAS_HEIGHT
const context = canvas.getContext('2d')
if (!context) throw new Error('Unable to create the built-in texture atlas.')
const builtInAtlas = await builtin.loadBuiltinAtlas()
context.drawImage(builtInAtlas, 0, 0)
builtInAtlas.close()
const availableBuiltinStates = builtin.listBuiltinBlockStates()
const builtInBlocks = builtin.loadBuiltinBlockResources(version, palette)
const previewBlocks = builtin.loadBuiltinBlockResources(version, [
...availableBuiltinStates,
...palette,
])
const textureUvs = builtin.builtinTextureUvs()
const missingTextureUv: [number, number, number, number] = textureUvs[
'minecraft:block/gray_concrete'
] ?? [0, 0, 1 / 64, 1 / 64]
const texture = configureSchematicTexture(new CanvasTexture(canvas))
texture.colorSpace = SRGBColorSpace
return {
atlas: canvas,
blockNames: builtin.loadBuiltinBlockNames(),
availableBlockStates: availableBuiltinStates,
workerResources: {
...builtInBlocks,
textureUvs,
missingTextureUv,
},
previewResources: {
...previewBlocks,
textureUvs,
missingTextureUv,
},
texture,
}
}

View File

@ -0,0 +1,11 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { filterNativeWalkMouseDelta } from './scene.ts'
test('native walk mouse input ignores cursor-wrap spikes', () => {
assert.equal(filterNativeWalkMouseDelta(24), 24)
assert.equal(filterNativeWalkMouseDelta(-128), -128)
assert.equal(filterNativeWalkMouseDelta(129), 0)
assert.equal(filterNativeWalkMouseDelta(Number.POSITIVE_INFINITY), 0)
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,72 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
clearRecentSchematics,
loadRecentSchematics,
recordRecentSchematic,
removeRecentSchematic,
} from './storage.ts'
function createStorage() {
const data = new Map<string, string>()
return {
getItem(key: string) {
return data.get(key) ?? null
},
setItem(key: string, value: string) {
data.set(key, value)
},
removeItem(key: string) {
data.delete(key)
},
}
}
test('recent schematics deduplicate sources and retain at most five entries', () => {
Object.defineProperty(globalThis, 'localStorage', { value: createStorage(), configurable: true })
for (let index = 0; index < 12; index += 1) {
recordRecentSchematic(
{ kind: 'external', path: `/tmp/${index}.litematic` },
`${index}.litematic`,
)
}
recordRecentSchematic({ kind: 'external', path: '/tmp/5.litematic' }, 'renamed.litematic')
const records = loadRecentSchematics()
assert.equal(records.length, 5)
assert.equal(records[0].fileName, 'renamed.litematic')
assert.equal(
records.filter(
(record) => record.source.kind === 'external' && record.source.path === '/tmp/5.litematic',
).length,
1,
)
})
test('recent schematics can be removed individually or cleared', () => {
Object.defineProperty(globalThis, 'localStorage', { value: createStorage(), configurable: true })
const records = recordRecentSchematic(
{ kind: 'instance', instanceId: 'demo', relativePath: 'house.schem' },
'house.schem',
)
assert.equal(removeRecentSchematic(records[0].id).length, 0)
recordRecentSchematic({ kind: 'external', path: '/tmp/house.schem' }, 'house.schem')
assert.deepEqual(clearRecentSchematics(), [])
assert.deepEqual(loadRecentSchematics(), [])
})
test('recent schematics retain instance-root file sources', () => {
Object.defineProperty(globalThis, 'localStorage', { value: createStorage(), configurable: true })
const source = {
kind: 'instance_file' as const,
instanceId: 'demo',
relativePath: 'config/worldedit/schematics/house.schem',
}
recordRecentSchematic(source, 'house.schem')
assert.deepEqual(loadRecentSchematics()[0].source, source)
recordRecentSchematic(
{ kind: 'instance', instanceId: 'demo', relativePath: source.relativePath },
'house.schem',
)
assert.equal(loadRecentSchematics().length, 2)
})

View File

@ -0,0 +1,65 @@
import type { SchematicPreviewSource } from './backend'
const RECENT_KEY = 'axolotl:lab:schematic-preview:recent:v1'
const MAX_RECENT = 5
export type RecentSchematic = {
id: string
source: SchematicPreviewSource
fileName: string
openedAt: number
}
function safeParse<T>(key: string, fallback: T): T {
try {
return JSON.parse(localStorage.getItem(key) ?? '') as T
} catch {
return fallback
}
}
function sourceId(source: SchematicPreviewSource) {
return source.kind === 'external'
? `external:${source.path}`
: `${source.kind}:${source.instanceId}:${source.relativePath}`
}
export function loadRecentSchematics(): RecentSchematic[] {
const records = safeParse<RecentSchematic[]>(RECENT_KEY, [])
if (!Array.isArray(records)) return []
return records
.filter(
(record) =>
record &&
typeof record.id === 'string' &&
typeof record.fileName === 'string' &&
typeof record.openedAt === 'number' &&
(record.source?.kind === 'external' ||
record.source?.kind === 'instance' ||
record.source?.kind === 'instance_file'),
)
.slice(0, MAX_RECENT)
}
export function recordRecentSchematic(
source: SchematicPreviewSource,
fileName: string,
): RecentSchematic[] {
const id = sourceId(source)
const records = loadRecentSchematics().filter((record) => record.id !== id)
records.unshift({ id, source, fileName, openedAt: Date.now() })
const next = records.slice(0, MAX_RECENT)
localStorage.setItem(RECENT_KEY, JSON.stringify(next))
return next
}
export function removeRecentSchematic(id: string): RecentSchematic[] {
const next = loadRecentSchematics().filter((record) => record.id !== id)
localStorage.setItem(RECENT_KEY, JSON.stringify(next))
return next
}
export function clearRecentSchematics(): RecentSchematic[] {
localStorage.removeItem(RECENT_KEY)
return []
}

View File

@ -0,0 +1,15 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { escapeSchematicCsvCell, normalizeSchematicLayerRange } from './utils.ts'
test('layer ranges remain ordered and inside structure bounds', () => {
assert.deepEqual(normalizeSchematicLayerRange(20, 5, -4, 16), [5, 16])
assert.deepEqual(normalizeSchematicLayerRange(-20, -10, -4, 16), [-4, -4])
})
test('material CSV cells escape quotes, commas, and line breaks', () => {
assert.equal(escapeSchematicCsvCell('minecraft:stone'), 'minecraft:stone')
assert.equal(escapeSchematicCsvCell('name,"quoted"'), '"name,""quoted"""')
assert.equal(escapeSchematicCsvCell('two\nlines'), '"two\nlines"')
})

View File

@ -0,0 +1,15 @@
export function normalizeSchematicLayerRange(
minimum: number,
maximum: number,
floor: number,
ceiling: number,
): [number, number] {
const low = Math.max(floor, Math.min(minimum, maximum, ceiling))
const high = Math.min(ceiling, Math.max(minimum, maximum, floor))
return [low, Math.max(low, high)]
}
export function escapeSchematicCsvCell(value: string | number) {
const text = String(value)
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text
}

Some files were not shown because too many files have changed in this diff Show More