feat:移除了弹窗,服务器添加sls
This commit is contained in:
@ -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'] },
|
||||
])
|
||||
})
|
||||
136
apps/app-frontend/src/lab/gradient-text/gradient-storage.ts
Normal file
136
apps/app-frontend/src/lab/gradient-text/gradient-storage.ts
Normal 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
|
||||
}
|
||||
115
apps/app-frontend/src/lab/gradient-text/gradient-text.test.ts
Normal file
115
apps/app-frontend/src/lab/gradient-text/gradient-text.test.ts
Normal 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),
|
||||
'AB34A',
|
||||
)
|
||||
})
|
||||
|
||||
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),
|
||||
'𞉀&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),
|
||||
'�&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',
|
||||
])
|
||||
})
|
||||
746
apps/app-frontend/src/lab/gradient-text/gradient-text.ts
Normal file
746
apps/app-frontend/src/lab/gradient-text/gradient-text.ts
Normal 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 === ' ' ? ' ' : 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
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()}`
|
||||
}
|
||||
70
apps/app-frontend/src/lab/mod-translation/backend.ts
Normal file
70
apps/app-frontend/src/lab/mod-translation/backend.ts
Normal 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))
|
||||
}
|
||||
207
apps/app-frontend/src/lab/mod-translation/i18n.ts
Normal file
207
apps/app-frontend/src/lab/mod-translation/i18n.ts
Normal 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 },
|
||||
]
|
||||
202
apps/app-frontend/src/lab/mod-translation/job-state.test.ts
Normal file
202
apps/app-frontend/src/lab/mod-translation/job-state.test.ts
Normal 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: [] } })
|
||||
})
|
||||
169
apps/app-frontend/src/lab/mod-translation/job-state.ts
Normal file
169
apps/app-frontend/src/lab/mod-translation/job-state.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
63
apps/app-frontend/src/lab/mod-translation/timeline.ts
Normal file
63
apps/app-frontend/src/lab/mod-translation/timeline.ts
Normal 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
|
||||
}
|
||||
182
apps/app-frontend/src/lab/mod-translation/types.ts
Normal file
182
apps/app-frontend/src/lab/mod-translation/types.ts
Normal 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
|
||||
}
|
||||
@ -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).
|
||||
BIN
apps/app-frontend/src/lab/recipe-generator/assets/datapack.png
Normal file
BIN
apps/app-frontend/src/lab/recipe-generator/assets/datapack.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
3456
apps/app-frontend/src/lab/recipe-generator/assets/items/1.12.json
Normal file
3456
apps/app-frontend/src/lab/recipe-generator/assets/items/1.12.json
Normal file
File diff suppressed because it is too large
Load Diff
3951
apps/app-frontend/src/lab/recipe-generator/assets/items/1.13.json
Normal file
3951
apps/app-frontend/src/lab/recipe-generator/assets/items/1.13.json
Normal file
File diff suppressed because it is too large
Load Diff
4386
apps/app-frontend/src/lab/recipe-generator/assets/items/1.14.json
Normal file
4386
apps/app-frontend/src/lab/recipe-generator/assets/items/1.14.json
Normal file
File diff suppressed because it is too large
Load Diff
4421
apps/app-frontend/src/lab/recipe-generator/assets/items/1.15.json
Normal file
4421
apps/app-frontend/src/lab/recipe-generator/assets/items/1.15.json
Normal file
File diff suppressed because it is too large
Load Diff
4886
apps/app-frontend/src/lab/recipe-generator/assets/items/1.16.json
Normal file
4886
apps/app-frontend/src/lab/recipe-generator/assets/items/1.16.json
Normal file
File diff suppressed because it is too large
Load Diff
5506
apps/app-frontend/src/lab/recipe-generator/assets/items/1.17.json
Normal file
5506
apps/app-frontend/src/lab/recipe-generator/assets/items/1.17.json
Normal file
File diff suppressed because it is too large
Load Diff
5511
apps/app-frontend/src/lab/recipe-generator/assets/items/1.18.json
Normal file
5511
apps/app-frontend/src/lab/recipe-generator/assets/items/1.18.json
Normal file
File diff suppressed because it is too large
Load Diff
5766
apps/app-frontend/src/lab/recipe-generator/assets/items/1.19.json
Normal file
5766
apps/app-frontend/src/lab/recipe-generator/assets/items/1.19.json
Normal file
File diff suppressed because it is too large
Load Diff
6281
apps/app-frontend/src/lab/recipe-generator/assets/items/1.20.json
Normal file
6281
apps/app-frontend/src/lab/recipe-generator/assets/items/1.20.json
Normal file
File diff suppressed because it is too large
Load Diff
7531
apps/app-frontend/src/lab/recipe-generator/assets/items/1.21.11.json
Normal file
7531
apps/app-frontend/src/lab/recipe-generator/assets/items/1.21.11.json
Normal file
File diff suppressed because it is too large
Load Diff
6761
apps/app-frontend/src/lab/recipe-generator/assets/items/1.21.2.json
Normal file
6761
apps/app-frontend/src/lab/recipe-generator/assets/items/1.21.2.json
Normal file
File diff suppressed because it is too large
Load Diff
6931
apps/app-frontend/src/lab/recipe-generator/assets/items/1.21.4.json
Normal file
6931
apps/app-frontend/src/lab/recipe-generator/assets/items/1.21.4.json
Normal file
File diff suppressed because it is too large
Load Diff
6986
apps/app-frontend/src/lab/recipe-generator/assets/items/1.21.5.json
Normal file
6986
apps/app-frontend/src/lab/recipe-generator/assets/items/1.21.5.json
Normal file
File diff suppressed because it is too large
Load Diff
7081
apps/app-frontend/src/lab/recipe-generator/assets/items/1.21.6.json
Normal file
7081
apps/app-frontend/src/lab/recipe-generator/assets/items/1.21.6.json
Normal file
File diff suppressed because it is too large
Load Diff
7086
apps/app-frontend/src/lab/recipe-generator/assets/items/1.21.7.json
Normal file
7086
apps/app-frontend/src/lab/recipe-generator/assets/items/1.21.7.json
Normal file
File diff suppressed because it is too large
Load Diff
7446
apps/app-frontend/src/lab/recipe-generator/assets/items/1.21.9.json
Normal file
7446
apps/app-frontend/src/lab/recipe-generator/assets/items/1.21.9.json
Normal file
File diff suppressed because it is too large
Load Diff
6671
apps/app-frontend/src/lab/recipe-generator/assets/items/1.21.json
Normal file
6671
apps/app-frontend/src/lab/recipe-generator/assets/items/1.21.json
Normal file
File diff suppressed because it is too large
Load Diff
7536
apps/app-frontend/src/lab/recipe-generator/assets/items/26.1.json
Normal file
7536
apps/app-frontend/src/lab/recipe-generator/assets/items/26.1.json
Normal file
File diff suppressed because it is too large
Load Diff
7691
apps/app-frontend/src/lab/recipe-generator/assets/items/26.2.json
Normal file
7691
apps/app-frontend/src/lab/recipe-generator/assets/items/26.2.json
Normal file
File diff suppressed because it is too large
Load Diff
405
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.14.json
Normal file
405
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.14.json
Normal 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"
|
||||
]
|
||||
}
|
||||
433
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.15.json
Normal file
433
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.15.json
Normal 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"
|
||||
]
|
||||
}
|
||||
604
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.16.json
Normal file
604
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.16.json
Normal 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"
|
||||
]
|
||||
}
|
||||
716
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.17.json
Normal file
716
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.17.json
Normal 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"
|
||||
]
|
||||
}
|
||||
745
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.18.json
Normal file
745
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.18.json
Normal 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"
|
||||
]
|
||||
}
|
||||
876
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.19.json
Normal file
876
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.19.json
Normal 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"
|
||||
]
|
||||
}
|
||||
1198
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.20.json
Normal file
1198
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.20.json
Normal file
File diff suppressed because it is too large
Load Diff
2101
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.21.11.json
Normal file
2101
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.21.11.json
Normal file
File diff suppressed because it is too large
Load Diff
1778
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.21.2.json
Normal file
1778
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.21.2.json
Normal file
File diff suppressed because it is too large
Load Diff
1778
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.21.4.json
Normal file
1778
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.21.4.json
Normal file
File diff suppressed because it is too large
Load Diff
1814
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.21.5.json
Normal file
1814
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.21.5.json
Normal file
File diff suppressed because it is too large
Load Diff
1853
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.21.6.json
Normal file
1853
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.21.6.json
Normal file
File diff suppressed because it is too large
Load Diff
1853
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.21.7.json
Normal file
1853
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.21.7.json
Normal file
File diff suppressed because it is too large
Load Diff
2009
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.21.9.json
Normal file
2009
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.21.9.json
Normal file
File diff suppressed because it is too large
Load Diff
1685
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.21.json
Normal file
1685
apps/app-frontend/src/lab/recipe-generator/assets/tags/1.21.json
Normal file
File diff suppressed because it is too large
Load Diff
2185
apps/app-frontend/src/lab/recipe-generator/assets/tags/26.1.json
Normal file
2185
apps/app-frontend/src/lab/recipe-generator/assets/tags/26.1.json
Normal file
File diff suppressed because it is too large
Load Diff
2943
apps/app-frontend/src/lab/recipe-generator/assets/tags/26.2.json
Normal file
2943
apps/app-frontend/src/lab/recipe-generator/assets/tags/26.2.json
Normal file
File diff suppressed because it is too large
Load Diff
2568
apps/app-frontend/src/lab/recipe-generator/assets/texture-atlas.json
Normal file
2568
apps/app-frontend/src/lab/recipe-generator/assets/texture-atlas.json
Normal file
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
51
apps/app-frontend/src/lab/recipe-generator/count-display.ts
Normal file
51
apps/app-frontend/src/lab/recipe-generator/count-display.ts
Normal 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
84
apps/app-frontend/src/lab/recipe-generator/datapack.test.ts
Normal file
84
apps/app-frontend/src/lab/recipe-generator/datapack.test.ts
Normal 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', [], []))
|
||||
})
|
||||
172
apps/app-frontend/src/lab/recipe-generator/datapack.ts
Normal file
172
apps/app-frontend/src/lab/recipe-generator/datapack.ts
Normal 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))
|
||||
}
|
||||
55
apps/app-frontend/src/lab/recipe-generator/display.ts
Normal file
55
apps/app-frontend/src/lab/recipe-generator/display.ts
Normal 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
|
||||
}
|
||||
49
apps/app-frontend/src/lab/recipe-generator/formatter.test.ts
Normal file
49
apps/app-frontend/src/lab/recipe-generator/formatter.test.ts
Normal 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')
|
||||
})
|
||||
67
apps/app-frontend/src/lab/recipe-generator/formatter.ts
Normal file
67
apps/app-frontend/src/lab/recipe-generator/formatter.ts
Normal 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
|
||||
}
|
||||
36
apps/app-frontend/src/lab/recipe-generator/identifier.ts
Normal file
36
apps/app-frontend/src/lab/recipe-generator/identifier.ts
Normal 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}`}`
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
28
apps/app-frontend/src/lab/recipe-generator/item-names.ts
Normal file
28
apps/app-frontend/src/lab/recipe-generator/item-names.ts
Normal 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
|
||||
}
|
||||
31
apps/app-frontend/src/lab/recipe-generator/messages.ts
Normal file
31
apps/app-frontend/src/lab/recipe-generator/messages.ts
Normal 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' },
|
||||
})
|
||||
108
apps/app-frontend/src/lab/recipe-generator/naming.test.ts
Normal file
108
apps/app-frontend/src/lab/recipe-generator/naming.test.ts
Normal 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')
|
||||
})
|
||||
220
apps/app-frontend/src/lab/recipe-generator/naming.ts
Normal file
220
apps/app-frontend/src/lab/recipe-generator/naming.ts
Normal 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) ?? '',
|
||||
}
|
||||
)
|
||||
}
|
||||
195
apps/app-frontend/src/lab/recipe-generator/preview-export.ts
Normal file
195
apps/app-frontend/src/lab/recipe-generator/preview-export.ts
Normal 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 })])
|
||||
}
|
||||
220
apps/app-frontend/src/lab/recipe-generator/recipe-engine.test.ts
Normal file
220
apps/app-frontend/src/lab/recipe-generator/recipe-engine.test.ts
Normal 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)
|
||||
})
|
||||
392
apps/app-frontend/src/lab/recipe-generator/recipe-engine.ts
Normal file
392
apps/app-frontend/src/lab/recipe-generator/recipe-engine.ts
Normal 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)
|
||||
}
|
||||
136
apps/app-frontend/src/lab/recipe-generator/recipe-layouts.ts
Normal file
136
apps/app-frontend/src/lab/recipe-generator/recipe-layouts.ts
Normal 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
|
||||
}
|
||||
112
apps/app-frontend/src/lab/recipe-generator/resources.ts
Normal file
112
apps/app-frontend/src/lab/recipe-generator/resources.ts
Normal 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()
|
||||
}
|
||||
23
apps/app-frontend/src/lab/recipe-generator/sources.json
Normal file
23
apps/app-frontend/src/lab/recipe-generator/sources.json
Normal 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
117
apps/app-frontend/src/lab/recipe-generator/types.ts
Normal file
117
apps/app-frontend/src/lab/recipe-generator/types.ts
Normal 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[]
|
||||
}
|
||||
153
apps/app-frontend/src/lab/recipe-generator/validation.ts
Normal file
153
apps/app-frontend/src/lab/recipe-generator/validation.ts
Normal 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] ?? []
|
||||
}
|
||||
261
apps/app-frontend/src/lab/recipe-generator/versions.ts
Normal file
261
apps/app-frontend/src/lab/recipe-generator/versions.ts
Normal 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'],
|
||||
}
|
||||
66
apps/app-frontend/src/lab/registry.ts
Normal file
66
apps/app-frontend/src/lab/registry.ts
Normal 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
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
187
apps/app-frontend/src/lab/schematic-preview/backend.ts
Normal file
187
apps/app-frontend/src/lab/schematic-preview/backend.ts
Normal 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 })
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
258
apps/app-frontend/src/lab/schematic-preview/block-preview.ts
Normal file
258
apps/app-frontend/src/lab/schematic-preview/block-preview.ts
Normal 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
|
||||
}
|
||||
@ -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'])
|
||||
})
|
||||
197
apps/app-frontend/src/lab/schematic-preview/builtin-resources.ts
Normal file
197
apps/app-frontend/src/lab/schematic-preview/builtin-resources.ts
Normal 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
|
||||
115
apps/app-frontend/src/lab/schematic-preview/editing.test.ts
Normal file
115
apps/app-frontend/src/lab/schematic-preview/editing.test.ts
Normal 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,
|
||||
)
|
||||
})
|
||||
256
apps/app-frontend/src/lab/schematic-preview/editing.ts
Normal file
256
apps/app-frontend/src/lab/schematic-preview/editing.ts
Normal 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 }
|
||||
}
|
||||
@ -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'])
|
||||
})
|
||||
@ -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)
|
||||
}
|
||||
438
apps/app-frontend/src/lab/schematic-preview/mesh-worker.ts
Normal file
438
apps/app-frontend/src/lab/schematic-preview/mesh-worker.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
238
apps/app-frontend/src/lab/schematic-preview/meshing.test.ts
Normal file
238
apps/app-frontend/src/lab/schematic-preview/meshing.test.ts
Normal 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'))
|
||||
})
|
||||
539
apps/app-frontend/src/lab/schematic-preview/meshing.ts
Normal file
539
apps/app-frontend/src/lab/schematic-preview/meshing.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
238
apps/app-frontend/src/lab/schematic-preview/resources.ts
Normal file
238
apps/app-frontend/src/lab/schematic-preview/resources.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
11
apps/app-frontend/src/lab/schematic-preview/scene.test.ts
Normal file
11
apps/app-frontend/src/lab/schematic-preview/scene.test.ts
Normal 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)
|
||||
})
|
||||
1065
apps/app-frontend/src/lab/schematic-preview/scene.ts
Normal file
1065
apps/app-frontend/src/lab/schematic-preview/scene.ts
Normal file
File diff suppressed because it is too large
Load Diff
72
apps/app-frontend/src/lab/schematic-preview/storage.test.ts
Normal file
72
apps/app-frontend/src/lab/schematic-preview/storage.test.ts
Normal 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)
|
||||
})
|
||||
65
apps/app-frontend/src/lab/schematic-preview/storage.ts
Normal file
65
apps/app-frontend/src/lab/schematic-preview/storage.ts
Normal 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 []
|
||||
}
|
||||
15
apps/app-frontend/src/lab/schematic-preview/utils.test.ts
Normal file
15
apps/app-frontend/src/lab/schematic-preview/utils.test.ts
Normal 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"')
|
||||
})
|
||||
15
apps/app-frontend/src/lab/schematic-preview/utils.ts
Normal file
15
apps/app-frontend/src/lab/schematic-preview/utils.ts
Normal 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
Reference in New Issue
Block a user