diff --git a/package-lock.json b/package-lock.json index 5fca9cc2..92ee3dfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,9 +30,10 @@ "brace": "^0.11.1", "buffer": "^6.0.3", "comment-json": "^4.1.1", - "deepslate": "^0.18.0", - "deepslate-1.18": "npm:deepslate@^0.9.0-beta.9", - "deepslate-1.18.2": "npm:deepslate@^0.9.0-beta.13", + "deepslate": "^0.21.0", + "deepslate-1.18": "npm:deepslate@0.9.0-beta.9", + "deepslate-1.18.2": "npm:deepslate@0.9.0", + "deepslate-1.20.4": "npm:deepslate@0.20.1", "highlight.js": "^11.5.1", "howler": "^2.2.3", "js-yaml": "^3.14.1", @@ -1628,9 +1629,9 @@ "dev": true }, "node_modules/deepslate": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.18.0.tgz", - "integrity": "sha512-mip9lv9ka0ksdaQ6OrfwQHFyr171vtTrmy1FAIWWy+owWbGnYiXioIS6uX4jEeQ9MMaWrBXwKHuPu5Hv+H7E3w==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.21.0.tgz", + "integrity": "sha512-Km1JotTlko37L81N3fKfxEerHwVhF3+c+sCUMF3X6gxNHsiG0RSjRpNY63pxuRahxsmyuOyFjgQhQKH2hd2alg==", "dependencies": { "gl-matrix": "^3.3.0", "md5": "^2.3.0", @@ -1660,6 +1661,17 @@ "pako": "^2.0.3" } }, + "node_modules/deepslate-1.20.4": { + "name": "deepslate", + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.20.1.tgz", + "integrity": "sha512-jY1PsO2uRfrUz3WsgSsv/tpetZuLQpTCs1TZB2bOJTREqyBIJOHiD9s2Q26Q1VcFTaA55YXHbHcTujAsKWI8Vw==", + "dependencies": { + "gl-matrix": "^3.3.0", + "md5": "^2.3.0", + "pako": "^2.0.3" + } + }, "node_modules/define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -3055,12 +3067,12 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -5453,9 +5465,9 @@ "dev": true }, "deepslate": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.18.0.tgz", - "integrity": "sha512-mip9lv9ka0ksdaQ6OrfwQHFyr171vtTrmy1FAIWWy+owWbGnYiXioIS6uX4jEeQ9MMaWrBXwKHuPu5Hv+H7E3w==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.21.0.tgz", + "integrity": "sha512-Km1JotTlko37L81N3fKfxEerHwVhF3+c+sCUMF3X6gxNHsiG0RSjRpNY63pxuRahxsmyuOyFjgQhQKH2hd2alg==", "requires": { "gl-matrix": "^3.3.0", "md5": "^2.3.0", @@ -5482,6 +5494,16 @@ "pako": "^2.0.3" } }, + "deepslate-1.20.4": { + "version": "npm:deepslate@0.20.1", + "resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.20.1.tgz", + "integrity": "sha512-jY1PsO2uRfrUz3WsgSsv/tpetZuLQpTCs1TZB2bOJTREqyBIJOHiD9s2Q26Q1VcFTaA55YXHbHcTujAsKWI8Vw==", + "requires": { + "gl-matrix": "^3.3.0", + "md5": "^2.3.0", + "pako": "^2.0.3" + } + }, "define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -6426,12 +6448,12 @@ "dev": true }, "micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "requires": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" } }, diff --git a/package.json b/package.json index c5e33326..d62aa6e4 100644 --- a/package.json +++ b/package.json @@ -36,9 +36,10 @@ "brace": "^0.11.1", "buffer": "^6.0.3", "comment-json": "^4.1.1", - "deepslate": "^0.18.0", - "deepslate-1.18": "npm:deepslate@^0.9.0-beta.9", - "deepslate-1.18.2": "npm:deepslate@^0.9.0-beta.13", + "deepslate-1.18": "npm:deepslate@0.9.0-beta.9", + "deepslate-1.18.2": "npm:deepslate@0.9.0", + "deepslate-1.20.4": "npm:deepslate@0.20.1", + "deepslate": "^0.21.0", "highlight.js": "^11.5.1", "howler": "^2.2.3", "js-yaml": "^3.14.1", diff --git a/src/app/Utils.ts b/src/app/Utils.ts index 71c03b7b..90877f2b 100644 --- a/src/app/Utils.ts +++ b/src/app/Utils.ts @@ -1,8 +1,8 @@ import type { DataModel } from '@mcschema/core' import { Path } from '@mcschema/core' import * as zip from '@zip.js/zip.js' -import type { Random } from 'deepslate' -import { Matrix3, Matrix4, Vector } from 'deepslate' +import type { Identifier, NbtTag, Random } from 'deepslate' +import { Matrix3, Matrix4, NbtByte, NbtCompound, NbtDouble, NbtInt, NbtList, NbtString, Vector } from 'deepslate' import type { mat3 } from 'gl-matrix' import { quat, vec2 } from 'gl-matrix' import yaml from 'js-yaml' @@ -593,3 +593,45 @@ export function genPath(gen: ConfigGenerator, version: VersionId) { } return path } + +export function jsonToNbt(value: unknown): NbtTag { + if (typeof value === 'string') { + return new NbtString(value) + } + if (typeof value === 'number') { + return Number.isInteger(value) ? new NbtInt(value) : new NbtDouble(value) + } + if (typeof value === 'boolean') { + return new NbtByte(value) + } + if (Array.isArray(value)) { + return new NbtList(value.map(jsonToNbt)) + } + if (typeof value === 'object' && value !== null) { + return new NbtCompound( + new Map(Object.entries(value ?? {}) + .map(([k, v]) => [k, jsonToNbt(v)])) + ) + } + return new NbtByte(0) +} + +export function mergeTextComponentStyles(text: unknown, style: Record) { + if (typeof text === 'string') { + return { ...style, text } + } + if (Array.isArray(text)) { + return { ...style, ...text[0], extra: text.slice(1) } + } + if (typeof text === 'object' && text !== null) { + return { ...style, ...text } + } + return { ...style, text: '' } +} + +export function makeDescriptionId(prefix: string, id: Identifier | undefined) { + if (id === undefined) { + return `${prefix}.unregistered_sadface` + } + return `${prefix}.${id.namespace}.${id.path.replaceAll('/', '.')}` +} diff --git a/src/app/components/ItemDisplay.tsx b/src/app/components/ItemDisplay.tsx index b0e5e88b..17f8e1ae 100644 --- a/src/app/components/ItemDisplay.tsx +++ b/src/app/components/ItemDisplay.tsx @@ -1,13 +1,15 @@ import type { ItemStack } from 'deepslate/core' import { Identifier } from 'deepslate/core' -import { useEffect, useRef, useState } from 'preact/hooks' +import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks' import { useVersion } from '../contexts/Version.jsx' import { useAsync } from '../hooks/useAsync.js' +import { fetchItemComponents } from '../services/index.js' +import { ResolvedItem } from '../services/ResolvedItem.js' import { renderItem } from '../services/Resources.js' import { getCollections } from '../services/Schemas.js' +import { jsonToNbt } from '../Utils.js' import { ItemTooltip } from './ItemTooltip.jsx' import { Octicon } from './Octicon.jsx' -import { itemHasGlint } from './previews/LootTable.js' interface Props { item: ItemStack, @@ -16,6 +18,7 @@ interface Props { advancedTooltip?: boolean, } export function ItemDisplay({ item, slotDecoration, tooltip, advancedTooltip }: Props) { + const { version } = useVersion() const el = useRef(null) const [tooltipOffset, setTooltipOffset] = useState<[number, number]>([0, 0]) const [tooltipSwap, setTooltipSwap] = useState(false) @@ -33,10 +36,20 @@ export function ItemDisplay({ item, slotDecoration, tooltip, advancedTooltip }: return () => el.current?.removeEventListener('mousemove', onMove) }, []) - const maxDamage = item.getItem().durability + const { value: baseComponents } = useAsync(() => fetchItemComponents(version), [version]) + const itemResolver = useCallback((item: ItemStack) => { + const base = baseComponents?.get(item.id.toString()) ?? new Map() + return new ResolvedItem(item, new Map([...base.entries()].map(([k, v]) => [k, jsonToNbt(v)]))) + }, [baseComponents]) + const resolvedItem = useMemo(() => { + return itemResolver(item) + }, [item, baseComponents]) + + const maxDamage = resolvedItem.getMaxDamage() + const damage = resolvedItem.getDamage() return
- + {item.count !== 1 && <> {item.count} @@ -44,9 +57,9 @@ export function ItemDisplay({ item, slotDecoration, tooltip, advancedTooltip }: } {slotDecoration && <> - {(maxDamage && item.tag.getNumber('Damage') > 0) && + {(maxDamage > 0 && damage > 0) && - + }
} @@ -55,16 +68,17 @@ export function ItemDisplay({ item, slotDecoration, tooltip, advancedTooltip }: right: (tooltipSwap ? `${tooltipOffset[0]}px` : undefined), top: `${tooltipOffset[1]}px`, }}> - +
} } -function ItemItself({ item }: Props) { +interface ResolvedProps extends Props { + item: ResolvedItem +} +function ItemItself({ item }: ResolvedProps) { const { version } = useVersion() - const hasGlint = itemHasGlint(item) - if (item.id.namespace !== Identifier.DEFAULT_NAMESPACE) { return Octicon.package } @@ -77,20 +91,20 @@ function ItemItself({ item }: Props) { const modelPath = `item/${item.id.path}` if (collections.get('model').includes('minecraft:' + modelPath)) { - return + return } return Octicon.package } -function RenderedItem({ item, hasGlint }: Props & { hasGlint: boolean }) { +function RenderedItem({ item }: ResolvedProps) { const { version } = useVersion() - const { value: src } = useAsync(() => renderItem(version, item), [version, item]) + const { value: src } = useAsync(() => renderItem(version, item.flatten()), [version, item]) if (src) { return <> {item.id.toString()} - {hasGlint &&
} + {item.hasFoil() &&
} } diff --git a/src/app/components/ItemDisplay1204.tsx b/src/app/components/ItemDisplay1204.tsx new file mode 100644 index 00000000..05b5d959 --- /dev/null +++ b/src/app/components/ItemDisplay1204.tsx @@ -0,0 +1,100 @@ +import type { ItemStack } from 'deepslate-1.20.4/core' +import { Identifier } from 'deepslate-1.20.4/core' +import { useEffect, useRef, useState } from 'preact/hooks' +import { useVersion } from '../contexts/Version.jsx' +import { useAsync } from '../hooks/useAsync.js' +import { renderItem } from '../services/Resources1204.js' +import { getCollections } from '../services/Schemas.js' +import { ItemTooltip1204 } from './ItemTooltip1204.jsx' +import { Octicon } from './Octicon.jsx' +import { itemHasGlint } from './previews/LootTable1204.js' + +interface Props { + item: ItemStack, + slotDecoration?: boolean, + tooltip?: boolean, + advancedTooltip?: boolean, +} +export function ItemDisplay1204({ item, slotDecoration, tooltip, advancedTooltip }: Props) { + const el = useRef(null) + const [tooltipOffset, setTooltipOffset] = useState<[number, number]>([0, 0]) + const [tooltipSwap, setTooltipSwap] = useState(false) + + useEffect(() => { + const onMove = (e: MouseEvent) => { + requestAnimationFrame(() => { + const { right, width } = el.current!.getBoundingClientRect() + const swap = right + 200 > document.body.clientWidth + setTooltipSwap(swap) + setTooltipOffset([(swap ? width - e.offsetX : e.offsetX) + 20, e.offsetY - 40]) + }) + } + el.current?.addEventListener('mousemove', onMove) + return () => el.current?.removeEventListener('mousemove', onMove) + }, []) + + const maxDamage = item.getItem().durability + + return
+ + {item.count !== 1 && <> + + {item.count} + {item.count} + + } + {slotDecoration && <> + {(maxDamage && item.tag.getNumber('Damage') > 0) && + + + } +
+ } + {tooltip !== false &&
+ +
} +
+} + +function ItemItself({ item }: Props) { + const { version } = useVersion() + + const hasGlint = itemHasGlint(item) + + if (item.id.namespace !== Identifier.DEFAULT_NAMESPACE) { + return Octicon.package + } + + const { value: collections } = useAsync(() => getCollections(version), []) + + if (collections === undefined) { + return null + } + + const modelPath = `item/${item.id.path}` + if (collections.get('model').includes('minecraft:' + modelPath)) { + return + } + + return Octicon.package +} + +function RenderedItem({ item, hasGlint }: Props & { hasGlint: boolean }) { + const { version } = useVersion() + const { value: src } = useAsync(() => renderItem(version, item), [version, item]) + + if (src) { + return <> + {item.id.toString()} + {hasGlint &&
} + + } + + return
+ {Octicon.package} +
+} diff --git a/src/app/components/ItemTooltip.tsx b/src/app/components/ItemTooltip.tsx index 36a48575..3fa6b666 100644 --- a/src/app/components/ItemTooltip.tsx +++ b/src/app/components/ItemTooltip.tsx @@ -1,137 +1,296 @@ -import type { ItemStack } from 'deepslate/core' -import { AttributeModifierOperation, Enchantment, Identifier, MobEffectInstance, Potion } from 'deepslate/core' -import { NbtList, NbtType } from 'deepslate/nbt' -import { useLocale } from '../contexts/Locale.jsx' -import { useVersion } from '../contexts/Version.jsx' -import { useAsync } from '../hooks/useAsync.js' -import { getLanguage, getTranslation } from '../services/Resources.js' -import { message } from '../Utils.js' +import type { MobEffectInstance, NbtTag } from 'deepslate' +import { ItemStack, NbtCompound, NbtList, PotionContents } from 'deepslate' +import { Identifier } from 'deepslate/core' +import type { ResolvedItem } from '../services/ResolvedItem.js' +import { makeDescriptionId, mergeTextComponentStyles } from '../Utils.js' import { TextComponent } from './TextComponent.jsx' interface Props { - item: ItemStack, + item: ResolvedItem, advanced?: boolean, + resolver: (item: ItemStack) => ResolvedItem, } -export function ItemTooltip({ item, advanced }: Props) { - const { version } = useVersion() - const { lang } = useLocale() - - const { value: language } = useAsync(() => getLanguage(version, lang), [version, lang]) - - const isPotion = item.is('potion') || item.is('splash_potion') || item.is('lingering_potion') - let displayName = item.tag.getCompound('display').getString('Name') - let name: string | undefined - if (displayName) { - try { - name = JSON.parse(displayName) - } catch (e) { - console.warn(`Error parsing display name '${displayName}': ${message(e)}`) - displayName = '' - } +export function ItemTooltip({ item, advanced, resolver }: Props) { + if (item.has('hide_tooltip')) { + return <> } - if (name === undefined) { - if (language) { - let descriptionId = `${item.id.namespace}.${item.id.path}` - if (isPotion) { - descriptionId = `${descriptionId}.effect.${Potion.fromNbt(item).name}` - } - name = getTranslation(language, `item.${descriptionId}`) - name ??= getTranslation(language, `block.${descriptionId}`) - } - name ??= item.id.path - .replace(/[_\/]/g, ' ') - .split(' ') - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') - } - const lore: any[] = [] - item.tag.getCompound('display').getList('Lore', NbtType.String).forEach((line) => { - try { - lore.push(JSON.parse(line['value'])) - } catch (e) { - console.warn(`Error parsing lore line '${line}': ${message(e)}`) - } - }) - - const durability = item.getItem().durability - const enchantments = (item.is('enchanted_book') ? item.tag.getList('StoredEnchantments', NbtType.Compound) : item.tag.getList('Enchantments', NbtType.Compound)) ?? NbtList.create() - - const effects = isPotion ? Potion.getAllEffects(item) : [] - const attributeModifiers = isPotion ? Potion.getAllAttributeModifiers(item) : [] return <> - 0 }} /> - {shouldShow(item, 'additional') && <> - {(!advanced && displayName.length === 0 && item.is('filled_map') && item.tag.hasNumber('map')) && <> - + + {!advanced && !item.has('custom_name') && item.is('filled_map') && item.has('map_id') && ( + tag.getAsNumber())], color: 'gray' }} /> + )} + {!item.has('hide_additional_tooltip') && <> + {item.is('filled_map') && advanced && (item.get('map_id', tag => tag.isNumber()) + ? tag.getAsNumber())], color: 'gray' }} /> + : + )} + {(item.id.path.endsWith('_banner') || item.is('shield')) && item.get('banner_patterns', tag => tag.isList() ? tag : [])?.map(layer => + + )} + {item.is('crossbow') && item.getChargedProjectile() && ( + + )} + {item.is('disc_fragment_5') && ( + + )} + {item.is('firework_rocket') && item.has('fireworks') && <> + {((item.get('fireworks', tag => tag.isCompound() ? tag.getNumber('flight_duration') : 0) ?? 0) > 0) && ( + tag.isCompound() ? tag.getNumber('flight_duration') : 0)], color: 'gray'}} /> + )} + {/* TODO: firework explosions */} } - {(item.is('filled_map') && advanced) && <> - + {item.is('firework_star') && item.has('firework_explosion') && ( + tag.isCompound() ? tag.getString('shape') : '')}`, color: 'gray' }} /> + // TODO: additional stuff + )} + {/* TODO: painting variants */} + {item.is('goat_horn') && item.has('instrument') && ( + tag.isCompound() + ? tag.get('description')?.toSimplifiedJson() + : { translate: makeDescriptionId('instrument', Identifier.parse(tag.getAsString()))} + ), { color: 'gray' })} /> + )} + {(item.is('lingering_potion') || item.is('potion') || item.is('splash_potion') || item.is('tipped_arrow')) && ( + tag) ?? NbtCompound.create())} factor={item.is('lingering_potion') ? 0.25 : item.is('tipped_arrow') ? 0.125 : 1} /> + )} + {/* TODO: mob buckets */} + {/* TODO: smithing templates */} + {item.is('written_book') && item.has('written_book_content') && <> + tag.isCompound() ? tag.getString('author') : undefined) ?? ''], color: 'gray' }} /> + tag.isCompound() ? tag.getNumber('generation') : undefined) ?? 0}`, color: 'gray' }} /> } - {isPotion && effects.length === 0 - ? - : effects.map(e => { - const color = e.effect.category === 'harmful' ? 'red' : 'blue' - let component: any = { translate: `effect.${e.effect.id.namespace}.${e.effect.id.path}` } - if (e.amplifier > 0) { - component = { translate: 'potion.withAmplifier', with: [component, { translate: `potion.potency.${e.amplifier}` }] } - } - if (e.duration > 20) { - component = { translate: 'potion.withDuration', with: [component, MobEffectInstance.formatDuration(e)] } - } - return - })} - {attributeModifiers.length > 0 && <> - - - {attributeModifiers.map(([attr, { amount, operation }]) => { - const a = operation === AttributeModifierOperation.addition ? amount * 100 : amount - if (amount > 0) { - return - } else if (amount < 0) { - return - } - return null + {(item.is('beehive') || item.is('bee_nest')) && <> + tag.isList() ? tag.length : 0) ?? 0, 3], color: 'gray' }} /> + tag.isCompound() ? tag.getString('honey_level') : 0) ?? 0, 5], color: 'gray' }} /> + } + {item.is('decorated_pot') && item.has('pot_decorations') && <> + + {item.get('pot_decorations', tag => tag.isList() ? tag.map(e => + + ) : undefined)} + } + {item.id.path.endsWith('_shulker_box') && <> + {item.has('container_loot') && ( + + )} + {(item.get('container', tag => tag.isList() ? tag.getItems() : []) ?? []).slice(0, 5).map(e => { + const subItem = resolver(ItemStack.fromNbt(e.isCompound() ? e.getCompound('item') : new NbtCompound())) + return })} + {(item.get('container', tag => tag.isList() ? tag.length : 0) ?? 0) > 5 && ( + tag.isList() ? tag.length : 0) ?? 0) - 5], italic: true }} /> + )} } + {/* TODO: spawner and trial spawner */} } - {shouldShow(item, 'enchantments') && enchantments.map(enchantment => { - const id = enchantment.getString('id') - const lvl = enchantment.getNumber('lvl') - const ench = Enchantment.REGISTRY.get(Identifier.parse(id)) - const component: any[] = [{ translate: `enchantment.${id.replace(':', '.')}`, color: ench?.isCurse ? 'red' : 'gray' }] - if (lvl !== 1 || ench?.maxLevel !== 1) { - component.push(' ', { translate: `enchantment.level.${lvl}`}) - } - return - })} - {item.tag.hasCompound('display') && <> - {shouldShow(item, 'dye') && item.tag.getCompound('display').hasNumber('color') && (advanced - ? - : )} - {lore.map((component) => )} + {item.showInTooltip('jukebox_playable') && <> + tag.isCompound() ? ( + tag.hasCompound('song') + ? tag.getCompound('song').get('description')?.toSimplifiedJson() + : { translate: makeDescriptionId('jukebox_song', Identifier.parse(tag.getString('song')))} + ) : {}) ?? {}, { color: 'gray'})} /> } - {shouldShow(item, 'unbreakable') && item.tag.getBoolean('Unbreakable') && } - {(advanced && item.tag.getNumber('Damage') > 0 && durability) && } + {item.showInTooltip('trim') && <> + + tag.isCompound() ? ( + tag.hasCompound('pattern') + ? tag.getCompound('pattern').get('description')?.toSimplifiedJson() + : { translate: makeDescriptionId('trim_pattern', Identifier.parse(tag.getString('pattern'))), color: BUILTIN_TRIM_MATERIALS[tag.getString('material').replace(/^minecraft:/, '')] ?? 'gray' } + ) : '')] }} /> + tag.isCompound() ? ( + tag.hasCompound('material') + ? tag.getCompound('material').get('description')?.toSimplifiedJson() + : { translate: makeDescriptionId('trim_material', Identifier.parse(tag.getString('material'))), color: BUILTIN_TRIM_MATERIALS[tag.getString('material').replace(/^minecraft:/, '')] ?? 'gray' } + ) : '')] }}/> + } + {item.showInTooltip('stored_enchantments') && ( + tag)} /> + )} + {item.showInTooltip('enchantments') && ( + tag)} /> + )} + {item.showInTooltip('dyed_color') && (advanced + ? tag.isCompound() ? tag.getNumber('rgb') : tag.getAsNumber())?.toString(16).padStart(6, '0')}`], color: 'gray' }} /> + : + )} + {item.getLore().map((component) => + + )} + {item.showInTooltip('attribute_modifiers') && ( + tag)} /> + )} + {item.showInTooltip('unbreakable') && ( + + )} + {item.has('ominous_bottle_amplifier') && ( + tag.getAsNumber()) ?? 0, duration: 120000 }]}} /> + )} + {/* TODO: creative-only suspicious stew effects */} + {/* TODO: can break and can place on */} + {advanced && item.isDamageable() && ( + + )} {advanced && <> - {item.tag.size > 0 && } + {item.getSize() > 0 && } } } -const TooltipMasks = { - enchantments: 1, - modifiers: 2, - unbreakable: 4, - can_destroy: 8, - can_place: 16, - additional: 32, - dye: 64, - upgrades: 128, +const BUILTIN_TRIM_MATERIALS: Record = { + amethyst: '#9A5CC6', + copper: '#B4684D', + diamond: '#6EECD2', + emerald: '#11A036', + gold: '#DEB12D', + iron: '#ECECEC', + lapis: '#416E97', + netherite: '#625859', + quartz: '#E3D4C4', + redstone: '#971607', } -function shouldShow(item: ItemStack, mask: keyof typeof TooltipMasks) { - const flags = item.tag.getNumber('HideFlags') - return (flags & TooltipMasks[mask]) === 0 +const HARMFUL_EFFECTS = new Set([ + 'minecraft:slowness', + 'minecraft:mining_fatigue', + 'minecraft:instant_damage', + 'minecraft:nausea', + 'minecraft:blindness', + 'minecraft:hunger', + 'minecraft:weakness', + 'minecraft:poison', + 'minecraft:wither', + 'minecraft:levitation', + 'minecraft:unluck', + 'minecraft:darkness', + 'minecraft:wind_charged', + 'minecraft:weaving', + 'minecraft:oozing', + 'minecraft:infested', +]) + +function PotionContentsTooltip({ contents, factor }: { contents: PotionContents, factor?: number }) { + const effects = PotionContents.getAllEffects(contents) + return <> + {effects.map(e => { + const color = HARMFUL_EFFECTS.has(e.effect.toString()) ? 'red' : 'blue' + let component: any = { translate: makeDescriptionId('effect', e.effect) } + if (e.amplifier > 0) { + component = { translate: 'potion.withAmplifier', with: [component, { translate: `potion.potency.${e.amplifier}` }] } + } + if (e.duration === -1 || e.duration > 20) { + component = { translate: 'potion.withDuration', with: [component, formatDuration(e, factor ?? 1)] } + } + return + })} + {effects.length === 0 && } + + +} + +function formatDuration(effect: MobEffectInstance, factor: number) { + if (effect.duration === -1) { + return { translate: 'effect.duration.infinite' } + } + const ticks = Math.floor(effect.duration * factor) + let seconds = Math.floor(ticks / 20) + let minutes = Math.floor(seconds / 60) + seconds %= 60 + const hours = Math.floor(minutes / 60) + minutes %= 60 + return `${hours > 0 ? `${hours}:` : ''}${minutes.toFixed().padStart(2, '0')}:${seconds.toFixed().padStart(2, '0')}` +} + +function EnchantmentsTooltip({ data }: { data: NbtTag | undefined }) { + if (!data || !data.isCompound()) { + return <> + } + const levels = data.hasCompound('levels') ? data.getCompound('levels') : data + return <> + {[...levels.keys()].map((key) => { + const level = levels.getNumber(key) + if (level <= 0) return <> + const id = Identifier.parse(key) + return + })} + +} + +const EQUIPMENT_GROUPS = [ + 'any', + 'mainhand', + 'offhand', + 'hand', + 'feet', + 'legs', + 'chest', + 'head', + 'armor', + 'body', +] + +const MODIFIER_OPERATIONS = [ + 'add_value', + 'add_multiplied_base', + 'add_multiplied_total', +] + +const NEGATIVE_ATTRIBUTES = new Set([ + 'minecraft:burning_time', + 'minecraft:fall_damage_multiplier', +]) + +const NEUTRAL_ATTRIBUTES = new Set([ + 'minecraft:gravity', + 'minecraft:scale', +]) + +function AttributeModifiersTooltip({ data }: { data: NbtTag | undefined }) { + const modifiers = data?.isList() ? data : data?.isCompound() ? data.getList('modifiers') : new NbtList() + + return <> + {EQUIPMENT_GROUPS.map(group => { + let first = true + return modifiers.map((e) => { + if (!e.isCompound()) return + const slot = e.has('slot') ? e.getString('slot') : 'any' + if (slot !== group) return + const wasFirst = first + first = false + + let amount = e.getNumber('amount') + const type = Identifier.parse(e.getString('type')) + const id = Identifier.parse(e.getString('id')) + const operation = MODIFIER_OPERATIONS.indexOf(e.getString('operation')) + let absolute = false + if (id.equals(Identifier.create('base_attack_damage'))) { + amount += 2 + absolute = true + } else if (id.equals(Identifier.create('base_attack_speed'))) { + amount += 4 + absolute = true + } + if (operation !== 0) { + amount *= 100 + } else if (type.equals(Identifier.create('knockback_resistance'))) { + amount *= 10 + } + + return <> + {wasFirst && <> + + + } + {absolute ? ( + + ) : amount > 0 ? ( + + ) : amount < 0 ? ( + + ) : <>} + + }) + })} + } diff --git a/src/app/components/ItemTooltip1204.tsx b/src/app/components/ItemTooltip1204.tsx new file mode 100644 index 00000000..ca7a0741 --- /dev/null +++ b/src/app/components/ItemTooltip1204.tsx @@ -0,0 +1,137 @@ +import type { ItemStack } from 'deepslate-1.20.4/core' +import { AttributeModifierOperation, Enchantment, Identifier, MobEffectInstance, Potion } from 'deepslate-1.20.4/core' +import { NbtList, NbtType } from 'deepslate-1.20.4/nbt' +import { useLocale } from '../contexts/Locale.jsx' +import { useVersion } from '../contexts/Version.jsx' +import { useAsync } from '../hooks/useAsync.js' +import { getLanguage, getTranslation } from '../services/Resources.js' +import { message } from '../Utils.js' +import { TextComponent } from './TextComponent.jsx' + +interface Props { + item: ItemStack, + advanced?: boolean, +} +export function ItemTooltip1204({ item, advanced }: Props) { + const { version } = useVersion() + const { lang } = useLocale() + + const { value: language } = useAsync(() => getLanguage(version, lang), [version, lang]) + + const isPotion = item.is('potion') || item.is('splash_potion') || item.is('lingering_potion') + let displayName = item.tag.getCompound('display').getString('Name') + let name: string | undefined + if (displayName) { + try { + name = JSON.parse(displayName) + } catch (e) { + console.warn(`Error parsing display name '${displayName}': ${message(e)}`) + displayName = '' + } + } + if (name === undefined) { + if (language) { + let descriptionId = `${item.id.namespace}.${item.id.path}` + if (isPotion) { + descriptionId = `${descriptionId}.effect.${Potion.fromNbt(item).name}` + } + name = getTranslation(language, `item.${descriptionId}`) + name ??= getTranslation(language, `block.${descriptionId}`) + } + name ??= item.id.path + .replace(/[_\/]/g, ' ') + .split(' ') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') + } + const lore: any[] = [] + item.tag.getCompound('display').getList('Lore', NbtType.String).forEach((line) => { + try { + lore.push(JSON.parse(line['value'])) + } catch (e) { + console.warn(`Error parsing lore line '${line}': ${message(e)}`) + } + }) + + const durability = item.getItem().durability + const enchantments = (item.is('enchanted_book') ? item.tag.getList('StoredEnchantments', NbtType.Compound) : item.tag.getList('Enchantments', NbtType.Compound)) ?? NbtList.create() + + const effects = isPotion ? Potion.getAllEffects(item) : [] + const attributeModifiers = isPotion ? Potion.getAllAttributeModifiers(item) : [] + + return <> + 0 }} /> + {shouldShow(item, 'additional') && <> + {(!advanced && displayName.length === 0 && item.is('filled_map') && item.tag.hasNumber('map')) && <> + + } + {(item.is('filled_map') && advanced) && <> + + } + {isPotion && effects.length === 0 + ? + : effects.map(e => { + const color = e.effect.category === 'harmful' ? 'red' : 'blue' + let component: any = { translate: `effect.${e.effect.id.namespace}.${e.effect.id.path}` } + if (e.amplifier > 0) { + component = { translate: 'potion.withAmplifier', with: [component, { translate: `potion.potency.${e.amplifier}` }] } + } + if (e.duration > 20) { + component = { translate: 'potion.withDuration', with: [component, MobEffectInstance.formatDuration(e)] } + } + return + })} + {attributeModifiers.length > 0 && <> + + + {attributeModifiers.map(([attr, { amount, operation }]) => { + const a = operation === AttributeModifierOperation.addition ? amount * 100 : amount + if (amount > 0) { + return + } else if (amount < 0) { + return + } + return null + })} + } + } + {shouldShow(item, 'enchantments') && enchantments.map(enchantment => { + const id = enchantment.getString('id') + const lvl = enchantment.getNumber('lvl') + const ench = Enchantment.REGISTRY.get(Identifier.parse(id)) + const component: any[] = [{ translate: `enchantment.${id.replace(':', '.')}`, color: ench?.isCurse ? 'red' : 'gray' }] + if (lvl !== 1 || ench?.maxLevel !== 1) { + component.push(' ', { translate: `enchantment.level.${lvl}`}) + } + return + })} + {item.tag.hasCompound('display') && <> + {shouldShow(item, 'dye') && item.tag.getCompound('display').hasNumber('color') && (advanced + ? + : )} + {lore.map((component) => )} + } + {shouldShow(item, 'unbreakable') && item.tag.getBoolean('Unbreakable') && } + {(advanced && item.tag.getNumber('Damage') > 0 && durability) && } + {advanced && <> + + {item.tag.size > 0 && } + } + +} + +const TooltipMasks = { + enchantments: 1, + modifiers: 2, + unbreakable: 4, + can_destroy: 8, + can_place: 16, + additional: 32, + dye: 64, + upgrades: 128, +} + +function shouldShow(item: ItemStack, mask: keyof typeof TooltipMasks) { + const flags = item.tag.getNumber('HideFlags') + return (flags & TooltipMasks[mask]) === 0 +} diff --git a/src/app/components/previews/LootTable.ts b/src/app/components/previews/LootTable.ts index 36e0eac4..0dc4da1e 100644 --- a/src/app/components/previews/LootTable.ts +++ b/src/app/components/previews/LootTable.ts @@ -1,15 +1,17 @@ +import { NbtByte, NbtDouble, NbtLong } from 'deepslate' import type { Random } from 'deepslate/core' -import { Enchantment, Identifier, ItemStack, LegacyRandom } from 'deepslate/core' -import { NbtCompound, NbtInt, NbtList, NbtShort, NbtString, NbtTag, NbtType } from 'deepslate/nbt' -import { clamp, getWeightedRandom, isObject } from '../../Utils.js' +import { Identifier, ItemStack, LegacyRandom } from 'deepslate/core' +import { NbtCompound, NbtInt, NbtList, NbtString, NbtTag } from 'deepslate/nbt' +import { ResolvedItem } from '../../services/ResolvedItem.js' import type { VersionId } from '../../services/Schemas.js' +import { clamp, isObject, jsonToNbt } from '../../Utils.js' export interface SlottedItem { slot: number, - item: ItemStack, + item: ResolvedItem, } -type ItemConsumer = (item: ItemStack) => void +type ItemConsumer = (item: ResolvedItem) => void const StackMixers = { container: fillContainer, @@ -25,6 +27,7 @@ interface LootOptions { daytime: number, weather: string, stackMixer: StackMixer, + getBaseComponents(id: string): Map, } interface LootContext extends LootOptions { @@ -39,7 +42,7 @@ interface LootContext extends LootOptions { export function generateLootTable(lootTable: any, options: LootOptions) { const ctx = createLootContext(options) - const result: ItemStack[] = [] + const result: ResolvedItem[] = [] generateTable(lootTable, item => result.push(item), ctx) const mixer = StackMixers[options.stackMixer] return mixer(result, ctx) @@ -47,7 +50,7 @@ export function generateLootTable(lootTable: any, options: LootOptions) { const SLOT_COUNT = 27 -function fillContainer(items: ItemStack[], ctx: LootContext): SlottedItem[] { +function fillContainer(items: ResolvedItem[], ctx: LootContext): SlottedItem[] { const slots = shuffle([...Array(SLOT_COUNT)].map((_, i) => i), ctx) const queue = items.filter(i => !i.is('air') && i.count > 1) @@ -83,7 +86,7 @@ function fillContainer(items: ItemStack[], ctx: LootContext): SlottedItem[] { return results } -function assignSlots(items: ItemStack[]): SlottedItem[] { +function assignSlots(items: ResolvedItem[]): SlottedItem[] { const results: SlottedItem[] = [] let slot = 0 for (const item of items) { @@ -98,7 +101,7 @@ function assignSlots(items: ItemStack[]): SlottedItem[] { return results } -function splitItem(item: ItemStack, count: number): ItemStack { +function splitItem(item: ResolvedItem, count: number): ResolvedItem { const splitCount = Math.min(count, item.count) const other = item.clone() other.count = splitCount @@ -130,6 +133,7 @@ function createLootContext(options: LootOptions): LootContext { luck: options.luck, weather: options.weather, dayTime: options.daytime, + // TODO getItemTag: () => [], getLootTable: () => ({ pools: [] }), getPredicate: () => [], @@ -229,15 +233,13 @@ function createItem(entry: any, consumer: ItemConsumer, ctx: LootContext) { } switch (type) { case 'item': - try { - entryConsumer(new ItemStack(Identifier.parse(entry.name), 1)) - } catch (e) {} + const id = Identifier.parse(entry.name) + entryConsumer(new ResolvedItem(new ItemStack(id, 1), ctx.getBaseComponents(id.toString()))) break case 'tag': ctx.getItemTag(entry.name).forEach(tagEntry => { - try { - entryConsumer(new ItemStack(Identifier.parse(tagEntry), 1)) - } catch (e) {} + const id = Identifier.parse(tagEntry) + entryConsumer(new ResolvedItem(new ItemStack(id, 1), ctx.getBaseComponents(id.toString()))) }) break case 'loot_table': @@ -253,7 +255,7 @@ function computeWeight(entry: any, luck: number) { return Math.max(Math.floor((entry.weight ?? 1) + (entry.quality ?? 0) * luck), 0) } -type LootFunction = (item: ItemStack, ctx: LootContext) => void +type LootFunction = (item: ResolvedItem, ctx: LootContext) => void function decorateFunctions(functions: any[], consumer: ItemConsumer, ctx: LootContext): ItemConsumer { const compositeFunction = composeFunctions(functions) @@ -277,44 +279,11 @@ function composeFunctions(functions: any[]): LootFunction { } const LootFunctions: Record LootFunction> = { - enchant_randomly: ({ enchantments }) => (item, ctx) => { - const isBook = item.is('book') - if (enchantments === undefined || enchantments.length === 0) { - enchantments = Enchantment.REGISTRY.map((_, ench) => ench) - .filter(ench => ench.isDiscoverable && (isBook || Enchantment.canEnchant(item, ench))) - .map(e => e.id.toString()) - } - if (enchantments.length > 0) { - const id = enchantments[ctx.random.nextInt(enchantments.length)] - let ench: Enchantment | undefined - try { - ench = Enchantment.REGISTRY.get(Identifier.parse(id)) - } catch (e) {} - if (ench === undefined) return - const lvl = ctx.random.nextInt(ench.maxLevel - ench.minLevel + 1) + ench.minLevel - if (isBook) { - item.tag = new NbtCompound() - item.count = 1 - } - enchantItem(item, { id, lvl }) - if (isBook) { - item.id = Identifier.create('enchanted_book') - } - } + enchant_randomly: () => () => { + // TODO }, - enchant_with_levels: ({ levels, treasure }) => (item, ctx) => { - const enchants = selectEnchantments(ctx.random, item, computeInt(levels, ctx), treasure) - const isBook = item.is('book') - if (isBook) { - item.count = 1 - item.tag = new NbtCompound() - } - for (const enchant of enchants) { - enchantItem(item, enchant) - } - if (isBook) { - item.id = Identifier.create('enchanted_book') - } + enchant_with_levels: () => () => { + // TODO }, exploration_map: ({ decoration }) => (item) => { if (!item.is('map')) { @@ -323,64 +292,167 @@ const LootFunctions: Record LootFunction> = { item.id = Identifier.create('filled_map') const color = decoration === 'mansion' ? 5393476 : decoration === 'monument' ? 3830373 : -1 if (color >= 0) { - getOrCreateTag(item, 'display').set('MapColor', new NbtInt(color)) + item.set('map_color', new NbtInt(color)) + } + }, + filtered: ({ item_filter, modifier }) => (item, ctx) => { + if (testItemPredicate(item_filter, item, ctx)) { + composeFunctions([modifier])(item, ctx) } }, limit_count: ({ limit }) => (item, ctx) => { const { min, max } = prepareIntRange(limit, ctx) - item.count = clamp(item.count, min, max ) + item.count = clamp(item.count, min, max) }, sequence: ({ functions }) => (item, ctx) => { if (!Array.isArray(functions)) return composeFunctions(functions)(item, ctx) }, + set_attributes: ({ modifiers, replace }) => (item, ctx) => { + if (!Array.isArray(modifiers)) return + const newModifiers = modifiers.map(m => { + if (typeof m !== 'object' || m === null) m = {} + return { + id: Identifier.parse(typeof m.id === 'string' ? m.id : ''), + type: Identifier.parse(typeof m.attribute === 'string' ? m.attribute : ''), + amount: computeFloat(m.amount, ctx), + operation: typeof m.operation === 'string' ? m.operation : 'add_value', + slot: typeof m.slot === 'string' ? m.slot : Array.isArray(m.slot) ? m.slot[ctx.random.nextInt(m.slot.length)] : 'any', + } + }) + updateAttributes(item, (modifiers) => { + if (replace === false) { + return [...modifiers, ...newModifiers] + } else { + return newModifiers + } + }) + }, + set_banner_pattern: ({ patterns, append }) => (item) => { + if (!Array.isArray(patterns)) return + if (append) { + const existing = item.get('banner_patterns', tag => tag.isList() ? tag : undefined) ?? new NbtList() + item.set('banner_patterns', new NbtList([...existing.getItems(), ...patterns.map(jsonToNbt)])) + } else { + item.set('banner_patterns', jsonToNbt(patterns)) + } + }, + set_book_cover: ({ title, author, generation }) => (item) => { + const content = item.get('written_book_content', tag => tag.isCompound() ? tag : undefined) ?? new NbtCompound() + const newContent = new NbtCompound() + .set('title', title !== undefined ? jsonToNbt(title) : content.get('title') ?? new NbtString('')) + .set('author', author !== undefined ? jsonToNbt(author) : content.get('author') ?? new NbtString('')) + .set('generation', generation !== undefined ? jsonToNbt(generation) : content.get('generation') ?? new NbtInt(0)) + .set('pages', content.getList('pages')) + .set('resolved', content.get('resolved') ?? new NbtByte(1)) + item.set('written_book_content', newContent) + }, + set_components: ({ components }) => (item) => { + for (const [key, value] of Object.entries(components)) { + item.set(key, jsonToNbt(value)) + } + }, + set_contents: ({ component, entries }) => (item, ctx) => { + const result = generateLootTable({ pools: [{ rolls: 1, entries }] }, ctx) + if (Identifier.parse(component).is('container')) { + item.set(component, new NbtList(result.map(s => new NbtCompound() + .set('slot', new NbtInt(s.slot)) + .set('item', s.item.toNbt()) + ))) + } else { + item.set(component, new NbtList(result.map(s => s.item.toNbt()))) + } + }, set_count: ({ count, add }) => (item, ctx) => { const oldCount = add ? (item.count) : 0 item.count = clamp(oldCount + computeInt(count, ctx), 0, 64) }, - set_damage: ({ damage, add }) => (item, ctx) => { - const maxDamage = item.getItem().durability - if (maxDamage) { - const oldDamage = add ? 1 - item.tag.getNumber('Damage') / maxDamage : 0 - const newDamage = 1 - clamp(computeFloat(damage, ctx) + oldDamage, 0, 1) - const finalDamage = Math.floor(newDamage * maxDamage) - item.tag.set('Damage', new NbtInt(finalDamage)) - } - }, - set_enchantments: ({ enchantments, add }) => (item, ctx) => { - Object.entries(enchantments).forEach(([id, level]) => { - const lvl = computeInt(level, ctx) - try { - enchantItem(item, { id: Identifier.parse(id), lvl }, add) - } catch (e) {} - }) - }, - set_lore: ({ lore, replace }) => (item) => { - const lines: string[] = lore.flatMap((line: any) => line !== undefined ? [JSON.stringify(line)] : []) - const newLore = replace ? lines : [...item.tag.getCompound('display').getList('Lore', NbtType.String).map(s => s.getAsString()), ...lines] - getOrCreateTag(item, 'display').set('Lore', new NbtList(newLore.map(l => new NbtString(l)))) - }, - set_name: ({ name }) => (item) => { - if (name !== undefined) { - const newName = JSON.stringify(name) - getOrCreateTag(item, 'display').set('Name', new NbtString(newName)) - } - }, - set_nbt: ({ tag }) => (item) => { + set_custom_data: ({ tag }) => (item) => { try { const newTag = NbtTag.fromString(tag) if (newTag.isCompound()) { - item.tag = newTag + item.set('custom_data', newTag) } } catch (e) {} }, + set_custom_model_data: ({ value }) => (item, ctx) => { + item.set('custom_model_data', new NbtInt(computeInt(value, ctx))) + }, + set_damage: ({ damage, add }) => (item, ctx) => { + if (item.isDamageable()) { + const maxDamage = item.getMaxDamage() + const oldDamage = add ? 1 - item.getDamage() / maxDamage : 0 + const newDamage = 1 - clamp(computeFloat(damage, ctx) + oldDamage, 0, 1) + const finalDamage = Math.floor(newDamage * maxDamage) + item.set('damage', new NbtInt(clamp(finalDamage, 0, maxDamage))) + } + }, + set_enchantments: ({ enchantments, add }) => (item, ctx) => { + if (item.is('book')) { + item.id = Identifier.create('enchanted_book') + item.base = ctx.getBaseComponents(item.id.toString()) + } + updateEnchantments(item, levels => { + Object.entries(enchantments).forEach(([id, level]) => { + id = Identifier.parse(id).toString() + if (add) { + levels.set(id, clamp((levels.get(id) ?? 0) + computeInt(level, ctx), 0, 255)) + } else { + levels.set(id, clamp(computeInt(level, ctx), 0, 255)) + } + }) + return levels + }) + }, + set_firework_explosion: () => () => { + // TODO + }, + set_fireworks: () => () => { + // TODO + }, + set_instrument: () => () => { + // TODO: depends on item tag + }, + set_item: ({ item: newId }) => (item, ctx) => { + if (typeof newId !== 'string') return + item.id = Identifier.parse(newId) + item.base = ctx.getBaseComponents(item.id.toString()) + }, + set_loot_table: ({ name, seed }) => (item) => { + item.set('container_loot', new NbtCompound() + .set('loot_table', new NbtString(Identifier.parse(typeof name === 'string' ? name : '').toString())) + .set('seed', new NbtLong(typeof seed === 'number' ? BigInt(seed) : BigInt(0)))) + }, + set_lore: ({ lore }) => (item) => { + const lines: string[] = lore.flatMap((line: any) => line !== undefined ? [JSON.stringify(line)] : []) + // TODO: account for mode + item.set('lore', new NbtList(lines.map(l => new NbtString(l)))) + }, + set_name: ({ name, target }) => (item) => { + if (name !== undefined) { + const newName = JSON.stringify(name) + item.set(target ?? 'custom_name', new NbtString(newName)) + } + }, + set_ominous_bottle_amplifier: ({ amplifier }) => (item, ctx) => { + item.set('ominous_bottle_amplifier', new NbtInt(computeInt(amplifier, ctx))) + }, set_potion: ({ id }) => (item) => { if (typeof id === 'string') { - try { - item.tag.set('Potion', new NbtString(Identifier.parse(id).toString())) - } catch (e) {} + item.set('potion_contents', new NbtString(id)) } }, + toggle_tooltips: ({ toggles }) => (item) => { + if (typeof toggles !== 'object' || toggles === null) return + Object.entries(toggles).forEach(([key, value]) => { + if (typeof value !== 'boolean') return + const tag = item.get(key, tag => tag) + if (tag === undefined) return + if (tag.isCompound()) { + item.set(key, tag.set('show_in_tooltip', new NbtByte(value))) + } + }) + }, } type LootCondition = (ctx: LootContext) => boolean @@ -427,14 +499,14 @@ const LootConditions: Record LootCondition> = { block_state_property: () => () => { return false // TODO }, - damage_source_properties: ({ predicate }) => (ctx) => { - return testDamageSourcePredicate(predicate, ctx) + damage_source_properties: () => () => { + return false // TODO }, - entity_properties: ({ predicate }) => (ctx) => { - return testEntityPredicate(predicate, ctx) + entity_properties: () => () => { + return false // TODO }, entity_scores: () => () => { - return false // TODO, + return false // TODO }, inverted: ({ term }) => (ctx) => { return !testCondition(term, ctx) @@ -442,11 +514,11 @@ const LootConditions: Record LootCondition> = { killed_by_player: ({ inverted }) => () => { return (inverted ?? false) === false // TODO }, - location_check: ({ predicate }) => (ctx) => { - return testLocationPredicate(predicate, ctx) + location_check: () => () => { + return false // TODO }, - match_tool: ({ predicate }) => (ctx) => { - return testItemPredicate(predicate, ctx) + match_tool: () => () => { + return false // TODO }, random_chance: ({ chance }) => (ctx) => { return ctx.random.nextFloat() < chance @@ -551,135 +623,107 @@ function prepareIntRange(range: any, ctx: LootContext) { return { min, max } } -function testItemPredicate(_predicate: any, _ctx: LootContext) { - return false // TODO -} - -function testLocationPredicate(_predicate: any, _ctx: LootContext) { - return false // TODO -} - -function testEntityPredicate(_predicate: any, _ctx: LootContext) { - return false // TODO -} - -function testDamageSourcePredicate(_predicate: any, _ctx: LootContext) { - return false // TODO -} - -function enchantItem(item: ItemStack, enchant: Enchant, additive?: boolean) { - const listKey = item.is('book') ? 'StoredEnchantments' : 'Enchantments' - if (!item.tag.hasList(listKey, NbtType.Compound)) { - item.tag.set(listKey, new NbtList()) - } - const enchantments = item.tag.getList(listKey, NbtType.Compound).getItems() - let index = enchantments.findIndex((e: any) => e.id === enchant.id) - if (index !== -1) { - const oldEnch = enchantments[index] - oldEnch.set('lvl', new NbtShort(Math.max(additive ? oldEnch.getNumber('lvl') + enchant.lvl : enchant.lvl, 0))) - } else { - enchantments.push(new NbtCompound().set('id', new NbtString(enchant.id.toString())).set('lvl', new NbtShort(enchant.lvl))) - index = enchantments.length - 1 - } - if (enchantments[index].getNumber('lvl') === 0) { - enchantments.splice(index, 1) - } - item.tag.set(listKey, new NbtList(enchantments)) -} - -function selectEnchantments(random: Random, item: ItemStack, levels: number, treasure: boolean): Enchant[] { - const enchantmentValue = item.getItem().enchantmentValue - if (enchantmentValue === undefined) { - return [] - } - levels += 1 + random.nextInt(Math.floor(enchantmentValue / 4 + 1)) + random.nextInt(Math.floor(enchantmentValue / 4 + 1)) - const f = (random.nextFloat() + random.nextFloat() - 1) * 0.15 - levels = clamp(Math.round(levels + levels * f), 1, Number.MAX_SAFE_INTEGER) - let available = getAvailableEnchantments(item, levels, treasure) - if (available.length === 0) { - return [] - } - const result: Enchant[] = [] - const first = getWeightedRandom(random, available, getEnchantWeight) - if (first) result.push(first) - - while (random.nextInt(50) <= levels) { - if (result.length > 0) { - const lastAdded = result[result.length - 1] - available = available.filter(a => Enchantment.isCompatible(Enchantment.REGISTRY.getOrThrow(a.id), Enchantment.REGISTRY.getOrThrow(lastAdded.id))) +function testItemPredicate(predicate: any, item: ResolvedItem, ctx: LootContext) { + if (!isObject(predicate)) return false + if (typeof predicate.items === 'string') { + if (predicate.items.startsWith('#')) { + return false // TODO: depends on item tag + } else if (!item.id.is(predicate.items)) { + return false + } + } else if (Array.isArray(predicate.items)) { + if (!predicate.items.some(i => typeof i === 'string' && item.id.is(i))) { + return false } - if (available.length === 0) break - const ench = getWeightedRandom(random, available, getEnchantWeight) - if (ench) result.push(ench) - levels = Math.floor(levels / 2) } - - return result -} - -const EnchantmentsRarityWeights = new Map(Object.entries({ - common: 10, - uncommon: 5, - rare: 2, - very_rare: 1, -})) - -function getEnchantWeight(ench: Enchant) { - return EnchantmentsRarityWeights.get(Enchantment.REGISTRY.get(ench.id)?.rarity ?? 'common') ?? 10 -} - -function getAvailableEnchantments(item: ItemStack, levels: number, treasure: boolean): Enchant[] { - const result: Enchant[] = [] - const isBook = item.is('book') - - Enchantment.REGISTRY.forEach((id, ench) => { - if ((!ench.isTreasure || treasure) && ench.isDiscoverable && (Enchantment.canEnchant(item, ench) || isBook)) { - for (let lvl = ench.maxLevel; lvl > ench.minLevel - 1; lvl -= 1) { - if (levels >= ench.minCost(lvl) && levels <= ench.maxCost(lvl)) { - result.push({ id, lvl }) - } + if (predicate.count !== undefined) { + const { min, max } = prepareIntRange(predicate.count, ctx) + console.log(min, max, item.count) + if (min > item.count || item.count > max) { + return false + } + } + if (isObject(predicate.components)) { + for (const [key, value] of Object.entries(predicate.components)) { + const tag = jsonToNbt(value) + const other = item.get(key, tag => tag) + if (!other || !tag.equals(other)) { + return false } } + } + // TODO: item sub predicates + return true +} + +function updateEnchantments(item: ResolvedItem, fn: (levels: Map) => Map) { + const type = item.is('book') ? 'stored_enchantments' : 'enchantments' + if (!item.has(type)) { + return + } + const levelsTag = item.get(type, tag => { + return tag.isCompound() ? tag.has('levels') ? tag.getCompound('levels') : tag : undefined + }) ?? new NbtCompound() + const showInTooltip = item.get(type, tag => { + return tag.isCompound() && tag.hasCompound('levels') ? tag.get('show_in_tooltip') : undefined + }) ?? new NbtByte(1) + const levels = new Map() + levelsTag.forEach((id, lvl) => { + levels.set(Identifier.parse(id).toString(), lvl.getAsNumber()) }) - return result + + const newLevels = fn(levels) + + const newLevelsTag = new NbtCompound() + for (const [key, lvl] of newLevels) { + if (lvl > 0) { + newLevelsTag.set(key, new NbtInt(lvl)) + } + } + const newTag = new NbtCompound() + .set('levels', newLevelsTag) + .set('show_in_tooltip', showInTooltip) + item.set(type, newTag) } -interface Enchant { +interface AttributeModifier { id: Identifier, - lvl: number, + type: Identifier, + amount: number, + operation: string, + slot: string, } -const AlwaysHasGlint = new Set([ - 'minecraft:debug_stick', - 'minecraft:enchanted_golden_apple', - 'minecraft:enchanted_book', - 'minecraft:end_crystal', - 'minecraft:experience_bottle', - 'minecraft:written_book', -]) +function updateAttributes(item: ResolvedItem, fn: (modifiers: AttributeModifier[]) => AttributeModifier[]) { + const modifiersTag = item.get('attribute_modifiers', tag => { + return tag.isCompound() ? tag.getList('modifiers') : tag.isList() ? tag : undefined + }) ?? new NbtList() + const showInTooltip = item.get('attribute_modifiers', tag => { + return tag.isCompound() ? tag.get('show_in_tooltip') : undefined + }) ?? new NbtByte(1) + const modifiers = modifiersTag.map(m => { + const root = m.isCompound() ? m : new NbtCompound() + return { + id: Identifier.parse(root.getString('id')), + type: Identifier.parse(root.getString('type')), + amount: root.getNumber('amount'), + operation: root.getString('operation'), + slot: root.getString('slot'), + } + }) -export function itemHasGlint(item: ItemStack) { - if (AlwaysHasGlint.has(item.id.toString())) { - return true - } - if (item.is('compass') && (item.tag.has('LodestoneDimension') || item.tag.has('LodestonePos'))) { - return true - } - if ((item.is('potion') || item.is('splash_potion') || item.is('lingering_potion')) && (item.tag.has('Potion') || item.tag.has('CustomPotionEffects'))) { - return true - } - if (item.tag.getList('Enchantments').length > 0 || item.tag.getList('StoredEnchantments').length > 0) { - return true - } - return false -} + const newModifiers = fn(modifiers) -function getOrCreateTag(item: ItemStack, key: string) { - if (item.tag.hasCompound(key)) { - return item.tag.getCompound(key) - } else { - const tag = new NbtCompound() - item.tag.set(key, tag) - return tag - } + const newModifiersTag = new NbtList(newModifiers.map(m => { + return new NbtCompound() + .set('id', new NbtString(m.id.toString())) + .set('type', new NbtString(m.type.toString())) + .set('amount', new NbtDouble(m.amount)) + .set('operation', new NbtString(m.operation)) + .set('slot', new NbtString(m.slot)) + })) + const newTag = new NbtCompound() + .set('modifiers', newModifiersTag) + .set('show_in_tooltip', showInTooltip) + item.set('attribute_modifiers', newTag) } diff --git a/src/app/components/previews/LootTable1204.ts b/src/app/components/previews/LootTable1204.ts new file mode 100644 index 00000000..8dd70324 --- /dev/null +++ b/src/app/components/previews/LootTable1204.ts @@ -0,0 +1,686 @@ +import type { Random } from 'deepslate-1.20.4/core' +import { Enchantment, Identifier, ItemStack, LegacyRandom } from 'deepslate-1.20.4/core' +import { NbtCompound, NbtInt, NbtList, NbtShort, NbtString, NbtTag, NbtType } from 'deepslate-1.20.4/nbt' +import type { VersionId } from '../../services/Schemas.js' +import { clamp, getWeightedRandom, isObject } from '../../Utils.js' + +export interface SlottedItem { + slot: number, + item: ItemStack, +} + +type ItemConsumer = (item: ItemStack) => void + +const StackMixers = { + container: fillContainer, + default: assignSlots, +} + +type StackMixer = keyof typeof StackMixers + +interface LootOptions { + version: VersionId, + seed: bigint, + luck: number, + daytime: number, + weather: string, + stackMixer: StackMixer, +} + +interface LootContext extends LootOptions { + random: Random, + luck: number + weather: string, + dayTime: number, + getItemTag(id: string): string[], + getLootTable(id: string): any, + getPredicate(id: string): any, +} + +export function generateLootTable(lootTable: any, options: LootOptions) { + const ctx = createLootContext(options) + const result: ItemStack[] = [] + generateTable(lootTable, item => result.push(item), ctx) + const mixer = StackMixers[options.stackMixer] + return mixer(result, ctx) +} + +const SLOT_COUNT = 27 + +function fillContainer(items: ItemStack[], ctx: LootContext): SlottedItem[] { + const slots = shuffle([...Array(SLOT_COUNT)].map((_, i) => i), ctx) + + const queue = items.filter(i => !i.is('air') && i.count > 1) + items = items.filter(i => !i.is('air') && i.count === 1) + + while (SLOT_COUNT - items.length - queue.length > 0 && queue.length > 0) { + const [itemA] = queue.splice(ctx.random.nextInt(queue.length), 1) + const splitCount = ctx.random.nextInt(Math.floor(itemA.count / 2)) + 1 + const itemB = splitItem(itemA, splitCount) + + for (const item of [itemA, itemB]) { + if (item.count > 1 && ctx.random.nextFloat() < 0.5) { + queue.push(item) + } else { + items.push(item) + } + } + } + + items.push(...queue) + shuffle(items, ctx) + + const results: SlottedItem[] = [] + for (const item of items) { + const slot = slots.pop() + if (slot === undefined) { + break + } + if (!item.is('air') && item.count > 0) { + results.push({ slot, item }) + } + } + return results +} + +function assignSlots(items: ItemStack[]): SlottedItem[] { + const results: SlottedItem[] = [] + let slot = 0 + for (const item of items) { + if (slot >= 27) { + break + } + if (!item.is('air') && item.count > 0) { + results.push({ slot, item }) + slot += 1 + } + } + return results +} + +function splitItem(item: ItemStack, count: number): ItemStack { + const splitCount = Math.min(count, item.count) + const other = item.clone() + other.count = splitCount + item.count = item.count - splitCount + return other +} + +function shuffle(array: T[], ctx: LootContext) { + let i = array.length + while (i > 0) { + const j = ctx.random.nextInt(i) + i -= 1; + [array[i], array[j]] = [array[j], array[i]] + } + return array +} + +function generateTable(table: any, consumer: ItemConsumer, ctx: LootContext) { + const tableConsumer = decorateFunctions(table.functions ?? [], consumer, ctx) + for (const pool of table.pools ?? []) { + generatePool(pool, tableConsumer, ctx) + } +} + +function createLootContext(options: LootOptions): LootContext { + return { + ...options, + random: new LegacyRandom(options.seed), + luck: options.luck, + weather: options.weather, + dayTime: options.daytime, + getItemTag: () => [], + getLootTable: () => ({ pools: [] }), + getPredicate: () => [], + } +} + +function generatePool(pool: any, consumer: ItemConsumer, ctx: LootContext) { + if (composeConditions(pool.conditions ?? [])(ctx)) { + const poolConsumer = decorateFunctions(pool.functions ?? [], consumer, ctx) + + const rolls = computeInt(pool.rolls, ctx) + Math.floor(computeFloat(pool.bonus_rolls, ctx) * ctx.luck) + for (let i = 0; i < rolls; i += 1) { + let totalWeight = 0 + const entries: any[] = [] + + // Expand entries + for (const entry of pool.entries ?? []) { + expandEntry(entry, ctx, (e) => { + const weight = computeWeight(e, ctx.luck) + if (weight > 0) { + entries.push(e) + totalWeight += weight + } + }) + } + + // Select random entry + if (totalWeight === 0 || entries.length === 0) { + continue + } + if (entries.length === 1) { + createItem(entries[0], poolConsumer, ctx) + continue + } + let remainingWeight = ctx.random.nextInt(totalWeight) + for (const entry of entries) { + remainingWeight -= computeWeight(entry, ctx.luck) + if (remainingWeight < 0) { + createItem(entry, poolConsumer, ctx) + break + } + } + } + } +} + +function expandEntry(entry: any, ctx: LootContext, consumer: (entry: any) => void): boolean { + if (!canEntryRun(entry, ctx)) { + return false + } + const type = entry.type?.replace(/^minecraft:/, '') + switch (type) { + case 'group': + for (const child of entry.children ?? []) { + expandEntry(child, ctx, consumer) + } + return true + case 'alternatives': + for (const child of entry.children ?? []) { + if (expandEntry(child, ctx, consumer)) { + return true + } + } + return false + case 'sequence': + for (const child of entry.children ?? []) { + if (!expandEntry(child, ctx, consumer)) { + return false + } + } + return true + case 'tag': + if (entry.expand) { + ctx.getItemTag(entry.tag ?? '').forEach(tagEntry => { + consumer({ type: 'item', name: tagEntry }) + }) + } else { + consumer(entry) + } + return true + default: + consumer(entry) + return true + } +} + +function canEntryRun(entry: any, ctx: LootContext): boolean { + return composeConditions(entry.conditions ?? [])(ctx) +} + +function createItem(entry: any, consumer: ItemConsumer, ctx: LootContext) { + const entryConsumer = decorateFunctions(entry.functions ?? [], consumer, ctx) + + const type = entry.type?.replace(/^minecraft:/, '') + if (typeof entry.name !== 'string') { + return + } + switch (type) { + case 'item': + try { + entryConsumer(new ItemStack(Identifier.parse(entry.name), 1)) + } catch (e) {} + break + case 'tag': + ctx.getItemTag(entry.name).forEach(tagEntry => { + try { + entryConsumer(new ItemStack(Identifier.parse(tagEntry), 1)) + } catch (e) {} + }) + break + case 'loot_table': + generateTable(ctx.getLootTable(entry.name), entryConsumer, ctx) + break + case 'dynamic': + // not relevant for this simulation + break + } +} + +function computeWeight(entry: any, luck: number) { + return Math.max(Math.floor((entry.weight ?? 1) + (entry.quality ?? 0) * luck), 0) +} + +type LootFunction = (item: ItemStack, ctx: LootContext) => void + +function decorateFunctions(functions: any[], consumer: ItemConsumer, ctx: LootContext): ItemConsumer { + const compositeFunction = composeFunctions(functions) + return (item) => { + compositeFunction(item, ctx) + consumer(item) + } +} + +function composeFunctions(functions: any[]): LootFunction { + return (item, ctx) => { + for (const fn of functions) { + if (Array.isArray(fn)) { + composeFunctions(fn) + } else if (composeConditions(fn.conditions ?? [])(ctx)) { + const type = fn.function?.replace(/^minecraft:/, ''); + (LootFunctions[type]?.(fn) ?? (i => i))(item, ctx) + } + } + } +} + +const LootFunctions: Record LootFunction> = { + enchant_randomly: ({ enchantments }) => (item, ctx) => { + const isBook = item.is('book') + if (enchantments === undefined || enchantments.length === 0) { + enchantments = Enchantment.REGISTRY.map((_, ench) => ench) + .filter(ench => ench.isDiscoverable && (isBook || Enchantment.canEnchant(item, ench))) + .map(e => e.id.toString()) + } + if (enchantments.length > 0) { + const id = enchantments[ctx.random.nextInt(enchantments.length)] + let ench: Enchantment | undefined + try { + ench = Enchantment.REGISTRY.get(Identifier.parse(id)) + } catch (e) {} + if (ench === undefined) return + const lvl = ctx.random.nextInt(ench.maxLevel - ench.minLevel + 1) + ench.minLevel + if (isBook) { + item.tag = new NbtCompound() + item.count = 1 + } + enchantItem(item, { id, lvl }) + if (isBook) { + item.id = Identifier.create('enchanted_book') + } + } + }, + enchant_with_levels: ({ levels, treasure }) => (item, ctx) => { + const enchants = selectEnchantments(ctx.random, item, computeInt(levels, ctx), treasure) + const isBook = item.is('book') + if (isBook) { + item.count = 1 + item.tag = new NbtCompound() + } + for (const enchant of enchants) { + enchantItem(item, enchant) + } + if (isBook) { + item.id = Identifier.create('enchanted_book') + } + }, + exploration_map: ({ decoration }) => (item) => { + if (!item.is('map')) { + return + } + item.id = Identifier.create('filled_map') + const color = decoration === 'mansion' ? 5393476 : decoration === 'monument' ? 3830373 : -1 + if (color >= 0) { + getOrCreateTag(item, 'display').set('MapColor', new NbtInt(color)) + } + }, + limit_count: ({ limit }) => (item, ctx) => { + const { min, max } = prepareIntRange(limit, ctx) + item.count = clamp(item.count, min, max ) + }, + sequence: ({ functions }) => (item, ctx) => { + if (!Array.isArray(functions)) return + composeFunctions(functions)(item, ctx) + }, + set_count: ({ count, add }) => (item, ctx) => { + const oldCount = add ? (item.count) : 0 + item.count = clamp(oldCount + computeInt(count, ctx), 0, 64) + }, + set_damage: ({ damage, add }) => (item, ctx) => { + const maxDamage = item.getItem().durability + if (maxDamage) { + const oldDamage = add ? 1 - item.tag.getNumber('Damage') / maxDamage : 0 + const newDamage = 1 - clamp(computeFloat(damage, ctx) + oldDamage, 0, 1) + const finalDamage = Math.floor(newDamage * maxDamage) + item.tag.set('Damage', new NbtInt(finalDamage)) + } + }, + set_enchantments: ({ enchantments, add }) => (item, ctx) => { + Object.entries(enchantments).forEach(([id, level]) => { + const lvl = computeInt(level, ctx) + try { + enchantItem(item, { id: Identifier.parse(id), lvl }, add) + } catch (e) {} + }) + }, + set_lore: ({ lore, replace }) => (item) => { + const lines: string[] = lore.flatMap((line: any) => line !== undefined ? [JSON.stringify(line)] : []) + const newLore = replace ? lines : [...item.tag.getCompound('display').getList('Lore', NbtType.String).map(s => s.getAsString()), ...lines] + getOrCreateTag(item, 'display').set('Lore', new NbtList(newLore.map(l => new NbtString(l)))) + }, + set_name: ({ name }) => (item) => { + if (name !== undefined) { + const newName = JSON.stringify(name) + getOrCreateTag(item, 'display').set('Name', new NbtString(newName)) + } + }, + set_nbt: ({ tag }) => (item) => { + try { + const newTag = NbtTag.fromString(tag) + if (newTag.isCompound()) { + item.tag = newTag + } + } catch (e) {} + }, + set_potion: ({ id }) => (item) => { + if (typeof id === 'string') { + try { + item.tag.set('Potion', new NbtString(Identifier.parse(id).toString())) + } catch (e) {} + } + }, +} + +type LootCondition = (ctx: LootContext) => boolean + +function composeConditions(conditions: any[]): LootCondition { + return (ctx) => { + for (const cond of conditions) { + if (!testCondition(cond, ctx)) { + return false + } + } + return true + } +} + +function testCondition(condition: any, ctx: LootContext): boolean { + if (Array.isArray(condition)) { + return composeConditions(condition)(ctx) + } + const type = condition.condition?.replace(/^minecraft:/, '') + return (LootConditions[type]?.(condition) ?? (() => true))(ctx) +} + +const LootConditions: Record LootCondition> = { + alternative: params => LootConditions['any_of'](params), + all_of: ({ terms }) => (ctx) => { + if (!Array.isArray(terms) || terms.length === 0) return true + for (const term of terms) { + if (!testCondition(term, ctx)) { + return false + } + } + return true + }, + any_of: ({ terms }) => (ctx) => { + if (!Array.isArray(terms) || terms.length === 0) return true + for (const term of terms) { + if (testCondition(term, ctx)) { + return true + } + } + return false + }, + block_state_property: () => () => { + return false // TODO + }, + damage_source_properties: ({ predicate }) => (ctx) => { + return testDamageSourcePredicate(predicate, ctx) + }, + entity_properties: ({ predicate }) => (ctx) => { + return testEntityPredicate(predicate, ctx) + }, + entity_scores: () => () => { + return false // TODO, + }, + inverted: ({ term }) => (ctx) => { + return !testCondition(term, ctx) + }, + killed_by_player: ({ inverted }) => () => { + return (inverted ?? false) === false // TODO + }, + location_check: ({ predicate }) => (ctx) => { + return testLocationPredicate(predicate, ctx) + }, + match_tool: ({ predicate }) => (ctx) => { + return testItemPredicate(predicate, ctx) + }, + random_chance: ({ chance }) => (ctx) => { + return ctx.random.nextFloat() < chance + }, + random_chance_with_looting: ({ chance, looting_multiplier }) => (ctx) => { + const level = 0 // TODO: get looting level from killer + const probability = chance + level * looting_multiplier + return ctx.random.nextFloat() < probability + + }, + reference: ({ name }) => (ctx) => { + const predicate = ctx.getPredicate(name) ?? [] + if (Array.isArray(predicate)) { + return composeConditions(predicate)(ctx) + } + return testCondition(predicate, ctx) + }, + survives_explosion: () => () => true, + table_bonus: ({ chances }) => (ctx) => { + const level = 0 // TODO: get enchantment level from tool + const chance = chances[clamp(level, 0, chances.length - 1)] + return ctx.random.nextFloat() < chance + }, + time_check: ({ value, period }) => (ctx) => { + let time = ctx.dayTime + if (period !== undefined) { + time = time % period + } + const { min, max } = prepareIntRange(value, ctx) + return min <= time && time <= max + }, + value_check: () => () => { + return false // TODO + }, + weather_check: ({ raining, thundering }) => (ctx) => { + const isRaining = ctx.weather === 'rain' || ctx.weather === 'thunder' + const isThundering = ctx.weather === 'thunder' + if (raining !== undefined && raining !== isRaining) return false + if (thundering !== undefined && thundering !== isThundering) return false + return true + }, +} + +function computeInt(provider: any, ctx: LootContext): number { + if (typeof provider === 'number') return Math.round(provider) + if (!isObject(provider)) return 0 + + const type = provider.type?.replace(/^minecraft:/, '') ?? 'uniform' + switch (type) { + case 'constant': + return Math.round(provider.value ?? 0) + case 'uniform': + const min = computeInt(provider.min, ctx) + const max = computeInt(provider.max, ctx) + return max < min ? min : ctx.random.nextInt(max - min + 1) + min + case 'binomial': + const n = computeInt(provider.n, ctx) + const p = computeFloat(provider.p, ctx) + let result = 0 + for (let i = 0; i < n; i += 1) { + if (ctx.random.nextFloat() < p) { + result += 1 + } + } + return result + } + return 0 +} + +function computeFloat(provider: any, ctx: LootContext): number { + if (typeof provider === 'number') return provider + if (!isObject(provider)) return 0 + + const type = provider.type?.replace(/^minecraft:/, '') ?? 'uniform' + switch (type) { + case 'constant': + return provider.value ?? 0 + case 'uniform': + const min = computeFloat(provider.min, ctx) + const max = computeFloat(provider.max, ctx) + return max < min ? min : ctx.random.nextFloat() * (max-min) + min + case 'binomial': + const n = computeInt(provider.n, ctx) + const p = computeFloat(provider.p, ctx) + let result = 0 + for (let i = 0; i < n; i += 1) { + if (ctx.random.nextFloat() < p) { + result += 1 + } + } + return result + } + return 0 +} + +function prepareIntRange(range: any, ctx: LootContext) { + if (typeof range === 'number') { + range = { min: range, max: range } + } + const min = computeInt(range.min, ctx) + const max = computeInt(range.max, ctx) + return { min, max } +} + +function testItemPredicate(_predicate: any, _ctx: LootContext) { + return false // TODO +} + +function testLocationPredicate(_predicate: any, _ctx: LootContext) { + return false // TODO +} + +function testEntityPredicate(_predicate: any, _ctx: LootContext) { + return false // TODO +} + +function testDamageSourcePredicate(_predicate: any, _ctx: LootContext) { + return false // TODO +} + +function enchantItem(item: ItemStack, enchant: Enchant, additive?: boolean) { + const listKey = item.is('book') ? 'StoredEnchantments' : 'Enchantments' + if (!item.tag.hasList(listKey, NbtType.Compound)) { + item.tag.set(listKey, new NbtList()) + } + const enchantments = item.tag.getList(listKey, NbtType.Compound).getItems() + let index = enchantments.findIndex((e: any) => e.id === enchant.id) + if (index !== -1) { + const oldEnch = enchantments[index] + oldEnch.set('lvl', new NbtShort(Math.max(additive ? oldEnch.getNumber('lvl') + enchant.lvl : enchant.lvl, 0))) + } else { + enchantments.push(new NbtCompound().set('id', new NbtString(enchant.id.toString())).set('lvl', new NbtShort(enchant.lvl))) + index = enchantments.length - 1 + } + if (enchantments[index].getNumber('lvl') === 0) { + enchantments.splice(index, 1) + } + item.tag.set(listKey, new NbtList(enchantments)) +} + +function selectEnchantments(random: Random, item: ItemStack, levels: number, treasure: boolean): Enchant[] { + const enchantmentValue = item.getItem().enchantmentValue + if (enchantmentValue === undefined) { + return [] + } + levels += 1 + random.nextInt(Math.floor(enchantmentValue / 4 + 1)) + random.nextInt(Math.floor(enchantmentValue / 4 + 1)) + const f = (random.nextFloat() + random.nextFloat() - 1) * 0.15 + levels = clamp(Math.round(levels + levels * f), 1, Number.MAX_SAFE_INTEGER) + let available = getAvailableEnchantments(item, levels, treasure) + if (available.length === 0) { + return [] + } + const result: Enchant[] = [] + const first = getWeightedRandom(random, available, getEnchantWeight) + if (first) result.push(first) + + while (random.nextInt(50) <= levels) { + if (result.length > 0) { + const lastAdded = result[result.length - 1] + available = available.filter(a => Enchantment.isCompatible(Enchantment.REGISTRY.getOrThrow(a.id), Enchantment.REGISTRY.getOrThrow(lastAdded.id))) + } + if (available.length === 0) break + const ench = getWeightedRandom(random, available, getEnchantWeight) + if (ench) result.push(ench) + levels = Math.floor(levels / 2) + } + + return result +} + +const EnchantmentsRarityWeights = new Map(Object.entries({ + common: 10, + uncommon: 5, + rare: 2, + very_rare: 1, +})) + +function getEnchantWeight(ench: Enchant) { + return EnchantmentsRarityWeights.get(Enchantment.REGISTRY.get(ench.id)?.rarity ?? 'common') ?? 10 +} + +function getAvailableEnchantments(item: ItemStack, levels: number, treasure: boolean): Enchant[] { + const result: Enchant[] = [] + const isBook = item.is('book') + + Enchantment.REGISTRY.forEach((id, ench) => { + if ((!ench.isTreasure || treasure) && ench.isDiscoverable && (Enchantment.canEnchant(item, ench) || isBook)) { + for (let lvl = ench.maxLevel; lvl > ench.minLevel - 1; lvl -= 1) { + if (levels >= ench.minCost(lvl) && levels <= ench.maxCost(lvl)) { + result.push({ id, lvl }) + } + } + } + }) + return result +} + +interface Enchant { + id: Identifier, + lvl: number, +} + +const AlwaysHasGlint = new Set([ + 'minecraft:debug_stick', + 'minecraft:enchanted_golden_apple', + 'minecraft:enchanted_book', + 'minecraft:end_crystal', + 'minecraft:experience_bottle', + 'minecraft:written_book', +]) + +export function itemHasGlint(item: ItemStack) { + console.log(item) + if (AlwaysHasGlint.has(item.id.toString())) { + return true + } + if (item.is('compass') && (item.tag.has('LodestoneDimension') || item.tag.has('LodestonePos'))) { + return true + } + if ((item.is('potion') || item.is('splash_potion') || item.is('lingering_potion')) && (item.tag.has('Potion') || item.tag.has('CustomPotionEffects'))) { + return true + } + if (item.tag.getList('Enchantments').length > 0 || item.tag.getList('StoredEnchantments').length > 0) { + return true + } + return false +} + +function getOrCreateTag(item: ItemStack, key: string) { + if (item.tag.hasCompound(key)) { + return item.tag.getCompound(key) + } else { + const tag = new NbtCompound() + item.tag.set(key, tag) + return tag + } +} diff --git a/src/app/components/previews/LootTablePreview.tsx b/src/app/components/previews/LootTablePreview.tsx index af522469..b8ab580b 100644 --- a/src/app/components/previews/LootTablePreview.tsx +++ b/src/app/components/previews/LootTablePreview.tsx @@ -1,15 +1,21 @@ import { DataModel } from '@mcschema/core' import { useMemo, useRef, useState } from 'preact/hooks' import { useLocale, useVersion } from '../../contexts/index.js' -import { clamp, randomSeed } from '../../Utils.js' +import { useAsync } from '../../hooks/useAsync.js' +import { checkVersion, fetchItemComponents } from '../../services/index.js' +import { clamp, jsonToNbt, randomSeed } from '../../Utils.js' import { Btn, BtnMenu, NumberInput } from '../index.js' import { ItemDisplay } from '../ItemDisplay.jsx' +import { ItemDisplay1204 } from '../ItemDisplay1204.jsx' import type { PreviewProps } from './index.js' import { generateLootTable } from './LootTable.js' +import { generateLootTable as generateLootTable1204 } from './LootTable1204.js' export const LootTablePreview = ({ data }: PreviewProps) => { const { locale } = useLocale() const { version } = useVersion() + const use1204 = checkVersion(version, undefined, '1.20.4') + const [seed, setSeed] = useState(randomSeed()) const [luck, setLuck] = useState(0) const [daytime, setDaytime] = useState(0) @@ -18,18 +24,31 @@ export const LootTablePreview = ({ data }: PreviewProps) => { const [advancedTooltips, setAdvancedTooltips] = useState(true) const overlay = useRef(null) + const { value: itemComponents } = useAsync(() => { + return use1204 ? Promise.resolve(undefined) : fetchItemComponents(version) + }, [use1204, version]) + const table = DataModel.unwrapLists(data) const state = JSON.stringify(table) const items = useMemo(() => { - return generateLootTable(table, { version, seed, luck, daytime, weather, stackMixer: mixItems ? 'container' : 'default' }) - }, [version, seed, luck, daytime, weather, mixItems, state]) + if (use1204) { + return generateLootTable1204(table, { version, seed, luck, daytime, weather, stackMixer: mixItems ? 'container' : 'default' }) + } else { + if (itemComponents === undefined) { + return [] + } + return generateLootTable(table, { version, seed, luck, daytime, weather, stackMixer: mixItems ? 'container' : 'default', getBaseComponents: (id) => new Map([...(itemComponents.get(id) ?? new Map()).entries()].map(([k, v]) => [k, jsonToNbt(v)])) }) + } + }, [version, seed, luck, daytime, weather, mixItems, state, itemComponents]) return <>
Container background {items.map(({ slot, item }) =>
- + {use1204 ? + : + }
)}
diff --git a/src/app/components/previews/RecipePreview.tsx b/src/app/components/previews/RecipePreview.tsx index 12f282c2..deaa0b2b 100644 --- a/src/app/components/previews/RecipePreview.tsx +++ b/src/app/components/previews/RecipePreview.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'preact/hooks' import { useLocale, useVersion } from '../../contexts/index.js' import { useAsync } from '../../hooks/useAsync.js' import { fetchAllPresets } from '../../services/index.js' +import { jsonToNbt } from '../../Utils.js' import { Btn, BtnMenu } from '../index.js' import { ItemDisplay } from '../ItemDisplay.jsx' import type { PreviewProps } from './index.js' @@ -172,18 +173,17 @@ function placeItems(recipe: any, animation: number, itemTags: Map) items.set(resultSlot, base) } } else if (typeof result === 'string') { - try { - items.set(resultSlot, new ItemStack(Identifier.parse(result), 1)) - } catch (e) {} + items.set(resultSlot, new ItemStack(Identifier.parse(result), 1)) } else if (typeof result === 'object' && result !== null) { const id = typeof result.id === 'string' ? result.id : typeof result.item === 'string' ? result.item : 'minecraft:air' - const count = typeof result.count === 'number' ? result.count : 1 - // TODO: add components - try { - items.set(resultSlot, new ItemStack(Identifier.parse(id), count)) - } catch (e) {} + if (id !== 'minecraft:air') { + const count = typeof result.count === 'number' ? result.count : 1 + const components = new Map(Object.entries(result.components ?? {}) + .map(([k, v]) => [k, jsonToNbt(v)])) + items.set(resultSlot, new ItemStack(Identifier.parse(id), count, components)) + } } return items @@ -195,19 +195,14 @@ function allIngredientChoices(ingredient: any, itemTags: Map): Item } if (typeof ingredient === 'object' && ingredient !== null) { if (typeof ingredient.item === 'string') { - try { - return [new ItemStack(Identifier.parse(ingredient.item), 1)] - } catch (e) {} + return [new ItemStack(Identifier.parse(ingredient.item), 1)] } else if (typeof ingredient.tag === 'string') { const tag: any = itemTags.get(ingredient.tag.replace(/^minecraft:/, '')) if (typeof tag === 'object' && tag !== null && Array.isArray(tag.values)) { return tag.values.flatMap((value: any) => { if (typeof value !== 'string') return [] if (value.startsWith('#')) return allIngredientChoices({ tag: value.slice(1) }, itemTags) - try { - return [new ItemStack(Identifier.parse(value), 1)] - } catch (e) {} - return [] + return [new ItemStack(Identifier.parse(value), 1)] }) } } diff --git a/src/app/services/DataFetcher.ts b/src/app/services/DataFetcher.ts index da03301d..e9d3be89 100644 --- a/src/app/services/DataFetcher.ts +++ b/src/app/services/DataFetcher.ts @@ -120,6 +120,31 @@ export async function fetchBlockStates(versionId: VersionId) { return result } +export async function fetchItemComponents(versionId: VersionId) { + console.debug(`[fetchItemComponents] ${versionId}`) + const version = config.versions.find(v => v.id === versionId)! + const result = new Map>() + try { + const data = await cachedFetch>>(`${mcmeta(version, 'summary')}/item_components/data.min.json`) + for (const [id, components] of Object.entries(data)) { + const base = new Map() + if (Array.isArray(components)) { // syntax before 1.21 + for (const entry of components) { + base.set(entry.type, entry.value) + } + } else { + for (const [key, value] of Object.entries(components)) { + base.set(key, value) + } + } + result.set('minecraft:' + id, base) + } + } catch (e) { + console.warn('Error occurred while fetching item components:', message(e)) + } + return result +} + export async function fetchPreset(versionId: VersionId, registry: string, id: string) { console.debug(`[fetchPreset] ${versionId} ${registry} ${id}`) const version = config.versions.find(v => v.id === versionId)! diff --git a/src/app/services/ResolvedItem.ts b/src/app/services/ResolvedItem.ts new file mode 100644 index 00000000..4363ac13 --- /dev/null +++ b/src/app/services/ResolvedItem.ts @@ -0,0 +1,194 @@ +import type { NbtTag } from 'deepslate' +import { Identifier, ItemStack } from 'deepslate' + +export class ResolvedItem extends ItemStack { + + constructor( + item: ItemStack, + public base: ReadonlyMap, + ) { + super(item.id, item.count, item.components) + } + + public static create(id: string | Identifier, count: number, components: Map, baseGetter: (id: string) => ReadonlyMap) { + if (typeof id === 'string') { + id = Identifier.parse(id) + } + const item = new ItemStack(id, count, components) + return new ResolvedItem(item, baseGetter(id.toString())) + } + + public clone(): ResolvedItem { + return new ResolvedItem(super.clone(), this.base) + } + + public flatten(): ItemStack { + const components = new Map(this.base) + for (const [key, value] of this.components) { + if (key.startsWith('!')) { + components.delete(key.slice(1)) + } else { + components.set(key, value) + } + } + return new ItemStack(this.id, this.count, components) + } + + private getTag(key: string) { + key = Identifier.parse(key).toString() + if (this.components.has(key)) { + return this.components.get(key) + } + if (this.components.has(`!${key}`)) { + return undefined + } + return this.base.get(key) + } + + public get(key: string, reader: (tag: NbtTag) => T) { + const tag = this.getTag(key) + return tag === undefined ? undefined : reader(tag) + } + + public has(key: string) { + return this.getTag(key) !== undefined + } + + public set(key: string, tag: NbtTag) { + key = Identifier.parse(key).toString() + this.components.set(key, tag) + } + + public getSize() { + const keys = new Set(this.base.keys()) + for (const key of this.components.keys()) { + if (key.startsWith('!')) { + keys.delete(key.slice(1)) + } else { + keys.add(key) + } + } + return keys.size + } + + public getMaxDamage() { + return this.get('max_damage', tag => tag.getAsNumber()) ?? 0 + } + + public getDamage() { + return this.get('damage', tag => tag.getAsNumber()) ?? 0 + } + + public isDamageable() { + return this.has('max_damage') && this.has('damage') && !this.has('unbreakable') + } + + public isDamaged() { + return this.isDamageable() && this.getDamage() > 0 + } + + public getMaxStackSize() { + return this.get('max_stack_size', tag => tag.getAsNumber()) ?? 1 + } + + public isStackable() { + return this.getMaxStackSize() > 1 && (!this.isDamageable() || !this.isDamaged()) + } + + public isEnchanted() { + return this.get('enchantments', tag => { + return tag.isCompound() ? tag.has('levels') ? tag.getCompound('levels').size > 0 : tag.size > 0 : false + }) ?? false + } + + public hasFoil() { + return this.has('enchantment_glint_override') || this.isEnchanted() + } + + public getLore() { + return this.get('lore', tag => { + return tag.isList() ? tag.map(e => e.getAsString()) : [] + }) ?? [] + } + + public showInTooltip(key: string) { + return this.get(key, tag => { + return tag.isCompound() && tag.has('show_in_tooltip') ? tag.getBoolean('show_in_tooltip') !== false : true + }) ?? false + } + + public getRarity() { + const rarity = this.get('rarity', tag => tag.isString() ? tag.getAsString() : undefined) ?? 'common' + if (!this.isEnchanted()) { + return rarity + } + if (rarity === 'common' || rarity === 'uncommon') { + return 'rare' + } + if (rarity === 'rare') { + return 'epic' + } + return rarity + } + + public getRarityColor() { + const rarity = this.getRarity() + if (rarity === 'epic') { + return 'light_purple' + } else if (rarity === 'rare') { + return 'aqua' + } else if (rarity === 'uncommon') { + return 'yellow' + } else { + return 'white' + } + } + + public getHoverName() { + const customName = this.get('custom_name', tag => tag.isString() ? tag.getAsString() : undefined) + if (customName) { + try { + return JSON.parse(customName) + } catch (e) { + return '(invalid custom name)' + } + } + + const bookTitle = this.get('written_book_content', tag => tag.isCompound() ? (tag.hasCompound('title') ? tag.getCompound('title').getString('raw') : tag.getString('title')) : undefined) + if (bookTitle && bookTitle.length > 0) { + return { text: bookTitle } + } + + const itemName = this.get('item_name', tag => tag.isString() ? tag.getAsString() : undefined) + try { + if (itemName) { + return JSON.parse(itemName) + } + } catch (e) {} + + const guess = this.id.path + .replace(/[_\/]/g, ' ') + .split(' ') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') + return { text: guess } + } + + public getStyledHoverName() { + return { text: '', extra: [this.getHoverName()], color: this.getRarityColor(), italic: this.has('custom_name') } + } + + public getDisplayName() { + // Does not use translation key "chat.square_brackets" due to limitations of TextComponent + return { text: '[', extra: [this.getStyledHoverName(), ']'], color: this.getRarityColor() } + } + + public getChargedProjectile() { + return this.get('charged_projectiles', tag => { + if (!tag.isList() || tag.length === 0) { + return undefined + } + return ItemStack.fromNbt(tag.getCompound(0)) + }) + } +} diff --git a/src/app/services/Resources1204.ts b/src/app/services/Resources1204.ts new file mode 100644 index 00000000..cc7493c8 --- /dev/null +++ b/src/app/services/Resources1204.ts @@ -0,0 +1,248 @@ +import type { BlockDefinitionProvider, BlockFlagsProvider, BlockModelProvider, BlockPropertiesProvider, ItemStack, TextureAtlasProvider, UV } from 'deepslate-1.20.4/render' +import { BlockDefinition, BlockModel, Identifier, ItemRenderer, TextureAtlas, upperPowerOfTwo } from 'deepslate-1.20.4/render' +import config from '../Config.js' +import { message } from '../Utils.js' +import { fetchLanguage, fetchResources } from './DataFetcher.js' +import type { VersionId } from './Schemas.js' + +const Resources: Record> = {} + +export async function getResources(version: VersionId) { + if (!Resources[version]) { + Resources[version] = (async () => { + try { + const { blockDefinitions, models, uvMapping, atlas} = await fetchResources(version) + Resources[version] = new ResourceManager(blockDefinitions, models, uvMapping, atlas) + return Resources[version] + } catch (e) { + console.error('Error: ', e) + throw new Error(`Cannot get resources for version ${version}: ${message(e)}`) + } + })() + return Resources[version] + } + return Resources[version] +} + +const RENDER_SIZE = 128 +const ItemRenderCache = new Map>() + +export async function renderItem(version: VersionId, item: ItemStack) { + const cache_key = `${version} ${item.toString()}` + const cached = ItemRenderCache.get(cache_key) + if (cached !== undefined) { + return cached + } + + const promise = (async () => { + const canvas = document.createElement('canvas') + canvas.width = RENDER_SIZE + canvas.height = RENDER_SIZE + const resources = await getResources(version) + const gl = canvas.getContext('webgl2', { preserveDrawingBuffer: true }) + if (!gl) { + throw new Error('Cannot get WebGL2 context') + } + const renderer = new ItemRenderer(gl, item, resources) + console.log('Rendering', item.toString()) + renderer.drawItem() + return canvas.toDataURL() + })() + ItemRenderCache.set(cache_key, promise) + return promise +} + +interface Resources extends BlockDefinitionProvider, BlockModelProvider, TextureAtlasProvider, BlockFlagsProvider, BlockPropertiesProvider {} + +export class ResourceManager implements Resources { + private readonly blockDefinitions: { [id: string]: BlockDefinition } + private readonly blockModels: { [id: string]: BlockModel } + private textureAtlas: TextureAtlas + + constructor(blockDefinitions: Map, models: Map, uvMapping: any, textureAtlas: HTMLImageElement) { + this.blockDefinitions = {} + this.blockModels = {} + this.textureAtlas = TextureAtlas.empty() + this.loadBlockDefinitions(blockDefinitions) + this.loadBlockModels(models) + this.loadBlockAtlas(textureAtlas, uvMapping) + } + + public getBlockDefinition(id: Identifier) { + return this.blockDefinitions[id.toString()] + } + + public getBlockModel(id: Identifier) { + return this.blockModels[id.toString()] + } + + public getTextureUV(id: Identifier) { + return this.textureAtlas.getTextureUV(id) + } + + public getTextureAtlas() { + return this.textureAtlas.getTextureAtlas() + } + + public getBlockFlags() { + return { opaque: false } + } + + public getBlockProperties() { + return null + } + + public getDefaultBlockProperties() { + return null + } + + private loadBlockModels(models: Map) { + [...models.entries()].forEach(([id, model]) => { + this.blockModels[Identifier.create(id).toString()] = BlockModel.fromJson(id, model) + }) + Object.values(this.blockModels).forEach(m => m.flatten(this)) + } + + private loadBlockDefinitions(definitions: Map) { + [...definitions.entries()].forEach(([id, definition]) => { + this.blockDefinitions[Identifier.create(id).toString()] = BlockDefinition.fromJson(id, definition) + }) + } + + private loadBlockAtlas(image: HTMLImageElement, textures: any) { + const atlasCanvas = document.createElement('canvas') + const w = upperPowerOfTwo(image.width) + const h = upperPowerOfTwo(image.height) + atlasCanvas.width = w + atlasCanvas.height = h + const ctx = atlasCanvas.getContext('2d')! + ctx.drawImage(image, 0, 0) + const imageData = ctx.getImageData(0, 0, w, h) + + const idMap: Record = {} + Object.keys(textures).forEach(id => { + const [u, v, du, dv] = textures[id] + const dv2 = (du !== dv && id.startsWith('block/')) ? du : dv + idMap[Identifier.create(id).toString()] = [u / w, v / h, (u + du) / w, (v + dv2) / h] + }) + this.textureAtlas = new TextureAtlas(imageData, idMap) + } +} + +export class ResourceWrapper implements Resources { + constructor( + private readonly wrapped: Resources, + private readonly overrides: Partial, + ) {} + + public getBlockDefinition(id: Identifier) { + return this.overrides.getBlockDefinition?.(id) ?? this.wrapped.getBlockDefinition(id) + } + + public getBlockModel(id: Identifier) { + return this.overrides.getBlockModel?.(id) ?? this.wrapped.getBlockModel(id) + } + + public getTextureUV(texture: Identifier) { + return this.overrides.getTextureUV?.(texture) ?? this.wrapped.getTextureUV(texture) + } + + public getTextureAtlas() { + return this.overrides.getTextureAtlas?.() ?? this.wrapped.getTextureAtlas() + } + + public getBlockFlags(id: Identifier) { + return this.overrides.getBlockFlags?.(id) ?? this.wrapped.getBlockFlags(id) + } + + public getBlockProperties(id: Identifier) { + return this.overrides.getBlockProperties?.(id) ?? this.wrapped.getBlockProperties(id) + } + + public getDefaultBlockProperties(id: Identifier) { + return this.overrides.getDefaultBlockProperties?.(id) ?? this.wrapped.getDefaultBlockProperties(id) + } +} + +export type Language = Record + +const Languages: Record> = {} + +export async function getLanguage(version: VersionId, lang: string = 'en') { + const mcLang = config.languages.find(l => l.code === lang)?.mc ?? 'en_us' + const cacheKey = `${version}_${mcLang}` + if (!Languages[cacheKey]) { + Languages[cacheKey] = (async () => { + try { + Languages[cacheKey] = await fetchLanguage(version, mcLang) + return Languages[cacheKey] + } catch (e) { + console.error('Error: ', e) + throw new Error(`Cannot get language '${mcLang}' for version ${version}: ${message(e)}`) + } + })() + return Languages[cacheKey] + } + return Languages[cacheKey] +} + +export function getTranslation(lang: Language, key: string, params?: string[]) { + const str = lang[key] + if (!str) return undefined + return replaceTranslation(str, params) +} + +export function replaceTranslation(src: string, params?: string[]) { + let out = '' + let i = 0 + let p = 0 + while (i < src.length) { + const c0 = src[i++] + if (c0 === '%') { // percent character + if (i >= src.length) { // INVALID: % + out += c0 + break + } + let c1 = src[i++] + if (c1 === '%') { // escape + out += '%' + } else if (c1 === 's' || c1 === 'd') { // short form %s + out += params?.[p++] ?? '' + } else if (c1 >= '0' && c1 <= '9') { + if (i >= src.length) { // INVALID: %2 + out += c0 + c1 + break + } + let num = '' + do { + num += c1 + c1 = src[i++] + } while (i < src.length && c1 >= '0' && c1 <= '9') + if (c1 === '$') { + if (i >= src.length) { // INVALID: %2$ + out += c0 + num + c1 + break + } + const c2 = src[i++] + if (c2 === 's' || c2 === 'd') { // long form %2$s + const pos = parseInt(num) - 1 + if (!params || isNaN(pos) || pos < 0 || pos >= params.length) { + out += '' + } else { + out += params[pos] + } + } else { // INVALID: %2$... + out += c0 + num + c1 + } + } else { // INVALID: %2... + out += c0 + num + } + } else { // INVALID: %... + out += c0 + } + } else { // normal character + out += c0 + } + } + return out +} diff --git a/src/app/services/Source.ts b/src/app/services/Source.ts index 00de8c47..6206b71b 100644 --- a/src/app/services/Source.ts +++ b/src/app/services/Source.ts @@ -1,6 +1,7 @@ -import { NbtByte, NbtCompound, NbtDouble, NbtInt, NbtList, NbtString, NbtTag } from 'deepslate' +import { NbtTag } from 'deepslate' import yaml from 'js-yaml' import { Store } from '../Store.js' +import { jsonToNbt } from '../Utils.js' const INDENTS: Record = { '2_spaces': 2, @@ -40,28 +41,6 @@ const FORMATS: Record [k, jsonToNbt(v)])) - ) - } - throw new Error(`Could not convert ${value} to NBT`) -} - export function stringifySource(data: unknown, format?: string, indent?: string) { return FORMATS[format ?? Store.getFormat()].stringify(data, INDENTS[indent ?? Store.getIndent()]) }