diff --git a/package-lock.json b/package-lock.json index 13fe256e..08c1df2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "brace": "^0.11.1", "buffer": "^6.0.3", "comment-json": "^4.1.1", - "deepslate": "^0.13.0", + "deepslate": "^0.13.2", "deepslate-1.18": "npm:deepslate@^0.9.0-beta.9", "deepslate-1.18.2": "npm:deepslate@^0.9.0-beta.13", "highlight.js": "^11.5.1", @@ -1933,9 +1933,9 @@ "dev": true }, "node_modules/deepslate": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.13.0.tgz", - "integrity": "sha512-16Dh/dOc8RLtiL0aQ3/h7bHUcIer+jAfFXehavhvHquEkxncQyZDq9YGktPTm9gTD2VGTDZeuIwZWAiMYkdfqw==", + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.13.2.tgz", + "integrity": "sha512-6pa9mgPu4A+RqYoN7AH79oKzzSNfvCJsrBKHE+AQjt20Uo33qJIRNG+2+sFHx84PAPJ3Z1CCnWWV+kBniD8E2g==", "dependencies": { "gl-matrix": "^3.3.0", "md5": "^2.3.0", @@ -6621,9 +6621,9 @@ "dev": true }, "deepslate": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.13.0.tgz", - "integrity": "sha512-16Dh/dOc8RLtiL0aQ3/h7bHUcIer+jAfFXehavhvHquEkxncQyZDq9YGktPTm9gTD2VGTDZeuIwZWAiMYkdfqw==", + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.13.2.tgz", + "integrity": "sha512-6pa9mgPu4A+RqYoN7AH79oKzzSNfvCJsrBKHE+AQjt20Uo33qJIRNG+2+sFHx84PAPJ3Z1CCnWWV+kBniD8E2g==", "requires": { "gl-matrix": "^3.3.0", "md5": "^2.3.0", diff --git a/package.json b/package.json index 449672a3..41011aa2 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "brace": "^0.11.1", "buffer": "^6.0.3", "comment-json": "^4.1.1", - "deepslate": "^0.13.0", + "deepslate": "^0.13.2", "deepslate-1.18": "npm:deepslate@^0.9.0-beta.9", "deepslate-1.18.2": "npm:deepslate@^0.9.0-beta.13", "highlight.js": "^11.5.1", diff --git a/src/.nojekyll b/public/.nojekyll similarity index 100% rename from src/.nojekyll rename to public/.nojekyll diff --git a/public/fonts/seven.ttf b/public/fonts/seven.ttf new file mode 100644 index 00000000..0b738f7f Binary files /dev/null and b/public/fonts/seven.ttf differ diff --git a/public/images/container.png b/public/images/container.png new file mode 100644 index 00000000..8a33932a Binary files /dev/null and b/public/images/container.png differ diff --git a/public/images/glint.png b/public/images/glint.png new file mode 100644 index 00000000..0a495e1d Binary files /dev/null and b/public/images/glint.png differ diff --git a/public/images/tooltip.png b/public/images/tooltip.png new file mode 100644 index 00000000..64e04d06 Binary files /dev/null and b/public/images/tooltip.png differ diff --git a/src/sitemap.txt b/public/sitemap.txt similarity index 100% rename from src/sitemap.txt rename to public/sitemap.txt diff --git a/src/app/Utils.ts b/src/app/Utils.ts index 864312a4..f65b0ad4 100644 --- a/src/app/Utils.ts +++ b/src/app/Utils.ts @@ -1,6 +1,7 @@ import type { DataModel } from '@mcschema/core' import { Path } from '@mcschema/core' import * as zip from '@zip.js/zip.js' +import type { Random } from 'deepslate/core' import yaml from 'js-yaml' import { route } from 'preact-router' import rfdc from 'rfdc' @@ -337,3 +338,21 @@ export async function computeIfAbsentAsync(map: Map, key: K, getter: map.set(key, value) return value } + +export function getWeightedRandom(random: Random, entries: T[], getWeight: (entry: T) => number) { + let totalWeight = 0 + for (const entry of entries) { + totalWeight += getWeight(entry) + } + if (totalWeight <= 0) { + return undefined + } + let n = random.nextInt(totalWeight) + for (const entry of entries) { + n -= getWeight(entry) + if (n < 0) { + return entry + } + } + return undefined +} diff --git a/src/app/components/ItemDisplay.tsx b/src/app/components/ItemDisplay.tsx index 60fc82ea..6cb89f2e 100644 --- a/src/app/components/ItemDisplay.tsx +++ b/src/app/components/ItemDisplay.tsx @@ -1,55 +1,100 @@ -import { useState } from 'preact/hooks' +import { useEffect, useRef, useState } from 'preact/hooks' import { useVersion } from '../contexts/Version.jsx' import { useAsync } from '../hooks/useAsync.js' +import type { Item } from '../previews/LootTable.js' +import { MaxDamageItems } from '../previews/LootTable.js' import { getAssetUrl } from '../services/DataFetcher.js' import { renderItem } from '../services/Resources.js' import { getCollections } from '../services/Schemas.js' +import { ItemTooltip } from './ItemTooltip.jsx' import { Octicon } from './Octicon.jsx' interface Props { - item: string, + item: Item, + slotDecoration?: boolean, + advancedTooltip?: boolean, } -export function ItemDisplay({ item }: Props) { +export function ItemDisplay({ item, slotDecoration, 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 = MaxDamageItems.get(item.id) + + return
+ + {item.count !== 1 && <> + + {item.count} + {item.count} + + } + {slotDecoration && <> + {(maxDamage && (item.tag?.Damage ?? 0) > 0) && + + + } +
+ } + +
+} + +function ItemItself({ item }: Props) { const { version } = useVersion() const [errored, setErrored] = useState(false) - if (errored || (item.includes(':') && !item.startsWith('minecraft:'))) { - return
- {Octicon.package} -
+ const isEnchanted = (item.tag?.Enchantments?.length ?? 0) > 0 || (item.tag?.StoredEnchantments?.length ?? 0) > 0 + + if (errored || (item.id.includes(':') && !item.id.startsWith('minecraft:'))) { + return Octicon.package } const { value: collections } = useAsync(() => getCollections(version), []) if (collections === undefined) { - return
+ return null } - const texturePath = `item/${item.replace(/^minecraft:/, '')}` + const texturePath = `item/${item.id.replace(/^minecraft:/, '')}` if (collections.get('texture').includes('minecraft:' + texturePath)) { - return
- setErrored(true)} /> -
+ const src = getAssetUrl(version, 'textures', texturePath) + return <> + setErrored(true)} draggable={false} /> + {isEnchanted &&
} + } - const modelPath = `block/${item.replace(/^minecraft:/, '')}` + const modelPath = `item/${item.id.replace(/^minecraft:/, '')}` if (collections.get('model').includes('minecraft:' + modelPath)) { - return
- -
+ return } - return
- {Octicon.package} -
+ return Octicon.package } -function RenderedItem({ item }: Props) { +function RenderedItem({ item, isEnchanted }: Props & { isEnchanted: boolean }) { const { version } = useVersion() - const { value: src } = useAsync(() => renderItem(version, item), [version, item]) + const { value: src } = useAsync(() => renderItem(version, item.id), [version, item]) if (src) { - return {item} + return <> + {item.id} + {isEnchanted &&
} + } return
diff --git a/src/app/components/ItemTooltip.tsx b/src/app/components/ItemTooltip.tsx new file mode 100644 index 00000000..7fb2edb8 --- /dev/null +++ b/src/app/components/ItemTooltip.tsx @@ -0,0 +1,57 @@ +import { useVersion } from '../contexts/Version.jsx' +import { useAsync } from '../hooks/useAsync.js' +import { getEnchantmentData, MaxDamageItems } from '../previews/LootTable.js' +import { getTranslation } from '../services/Resources.js' +import { TextComponent } from './TextComponent.jsx' + +interface Props { + id: string, + tag?: any, + advanced?: boolean, + offset?: [number, number], + swap?: boolean, +} +export function ItemTooltip({ id, tag, advanced, offset = [0, 0], swap }: Props) { + const { version } = useVersion() + const { value: translatedName } = useAsync(() => { + const key = id.split(':').join('.') + return getTranslation(version, `item.${key}`) ?? getTranslation(version, `block.${key}`) + }, [version, id]) + const displayName = tag?.display?.Name + const name = displayName ? JSON.parse(displayName) : (translatedName ?? fakeTranslation(id)) + + const maxDamage = MaxDamageItems.get(id) + + return
+ + {tag?.Enchantments?.map(({ id, lvl }: { id: string, lvl: number }) => { + const ench = getEnchantmentData(id) + const component: any[] = [{ translate: `enchantment.${id.replace(':', '.')}`, color: ench?.curse ? 'red' : 'gray' }] + if (lvl !== 1 || ench?.maxLevel !== 1) { + component.push(' ', { translate: `enchantment.level.${lvl}`}) + } + return + })} + {tag?.display && <> + {tag?.display?.color && (advanced + ? + : )} + {(tag?.display?.Lore ?? []).map((line: any) => )} + } + {tag?.Unbreakable === true && } + {(advanced && (tag?.Damage ?? 0) > 0 && maxDamage) && } + {advanced && <> + + {tag && } + } +
+} + +function fakeTranslation(str: string) { + const raw = str.replace(/minecraft:/, '').replaceAll('_', ' ') + return raw[0].toUpperCase() + raw.slice(1) +} diff --git a/src/app/components/TextComponent.tsx b/src/app/components/TextComponent.tsx new file mode 100644 index 00000000..2e0b26c9 --- /dev/null +++ b/src/app/components/TextComponent.tsx @@ -0,0 +1,129 @@ +import { useMemo } from 'preact/hooks' +import { useVersion } from '../contexts/Version.jsx' +import { useAsync } from '../hooks/useAsync.js' +import { getTranslation } from '../services/Resources.js' + +interface StyleData { + color?: string, + bold?: boolean, + italic?: boolean, + underlined?: boolean, + strikethrough?: boolean, +} + +interface PartData extends StyleData { + text?: string, + translate?: string, + with?: string[], +} + +interface Props { + component: unknown, + base?: StyleData, + shadow?: boolean, +} +export function TextComponent({ component, base = { color: 'white' }, shadow = true }: Props) { + const state = JSON.stringify(component) + const parts = useMemo(() => { + const parts: PartData[] = [] + visitComponent(component, el => parts.push(el)) + return parts + }, [state]) + + return
+ {shadow &&
+ {parts.map(p => )} +
} +
+ {parts.map(p => )} +
+
+} + +function visitComponent(component: unknown, consumer: (c: PartData) => void) { + if (typeof component === 'string' || typeof component === 'number') { + consumer({ text: component.toString() }) + } else if (Array.isArray(component)) { + const base = component[0] + visitComponent(base, consumer) + for (const c of component.slice(1)) { + visitComponent(c, d => consumer(inherit(d, base))) + } + } else if (typeof component === 'object' && component !== null) { + if ('text' in component) { + consumer(component) + } else if ('translate' in component) { + consumer(component) + } else if ('score' in component) { + consumer({ ...component, text: '123' }) + } else if ('selector' in component) { + consumer({ ...component, text: 'Steve' }) + } else if ('keybind' in component) { + consumer({ ...component, text: (component as any).keybind }) + } else if ('nbt' in component) { + consumer({ ...component, text: (component as any).nbt }) + } + if ('extra' in component) { + for (const e of (component as any).extra) { + visitComponent(e, c => consumer(inherit(c, component))) + } + } + } +} + +function inherit(component: object, base: PartData) { + return { + color: base.color, + bold: base.bold, + italic: base.italic, + underlined: base.underlined, + strikethrough: base.strikethrough, + ...component, + } +} + +const TextColors = { + black: ['#000', '#000'], + dark_blue: ['#00A', '#00002A'], + dark_green: ['#0A0', '#002A00'], + dark_aqua: ['#0AA', '#002A2A'], + dark_red: ['#A00', '#2A0000'], + dark_purple: ['#A0A', '#2A002A'], + gold: ['#FA0', '#2A2A00'], + gray: ['#AAA', '#2A2A2A'], + dark_gray: ['#555', '#151515'], + blue: ['#55F', '#15153F'], + green: ['#5F5', '#153F15'], + aqua: ['#5FF', '#153F3F'], + red: ['#F55', '#3F1515'], + light_purple: ['#F5F', '#3F153F'], + yellow: ['#FF5', '#3F3F15'], + white: ['#FFF', '#3F3F3F'], +} + +type TextColorKey = keyof typeof TextColors +const TextColorKeys = Object.keys(TextColors) + +function TextPart({ part, shadow }: { part: PartData, shadow?: boolean }) { + if (part.translate) { + const { version } = useVersion() + const { value: translated } = useAsync(() => { + return getTranslation(version, part.translate!, part.with) + }, [version, part.translate, ...part.with ?? []]) + return {translated ?? part.translate} + } + return {part.text} +} + +function createStyle(style: StyleData, shadow?: boolean) { + return { + color: style.color && (TextColorKeys.includes(style.color) + ? TextColors[style.color as TextColorKey][shadow ? 1 : 0] + : shadow ? 'transparent' : style.color), + fontWeight: (style.bold === true) ? 'bold' : undefined, + fontStyle: (style.italic === true) ? 'italic' : undefined, + textDecoration: (style.underlined === true) + ? (style.strikethrough === true) ? 'underline line-through' : 'underline' + : (style.strikethrough === true) ? 'line-through' : undefined, + } +} diff --git a/src/app/components/generator/PreviewPanel.tsx b/src/app/components/generator/PreviewPanel.tsx index 46049809..35a4f04b 100644 --- a/src/app/components/generator/PreviewPanel.tsx +++ b/src/app/components/generator/PreviewPanel.tsx @@ -5,8 +5,9 @@ import { useModel } from '../../hooks/index.js' import type { VersionId } from '../../services/index.js' import { checkVersion } from '../../services/index.js' import { BiomeSourcePreview, DecoratorPreview, DensityFunctionPreview, NoisePreview, NoiseSettingsPreview } from '../previews/index.js' +import { LootTablePreview } from '../previews/LootTablePreview.jsx' -export const HasPreview = ['dimension', 'worldgen/density_function', 'worldgen/noise', 'worldgen/noise_settings', 'worldgen/configured_feature', 'worldgen/placed_feature'] +export const HasPreview = ['loot_table', 'dimension', 'worldgen/density_function', 'worldgen/noise', 'worldgen/noise_settings', 'worldgen/configured_feature', 'worldgen/placed_feature'] type PreviewPanelProps = { model: DataModel | undefined, @@ -24,6 +25,11 @@ export function PreviewPanel({ model, version, id, shown }: PreviewPanelProps) { if (!model) return <> + if (id === 'loot_table') { + const data = model.get(new Path([])) + if (data) return + } + if (id === 'dimension' && model.get(new Path(['generator', 'type']))?.endsWith('noise')) { const data = model.get(new Path(['generator', 'biome_source'])) if (data) return diff --git a/src/app/components/previews/BiomeSourcePreview.tsx b/src/app/components/previews/BiomeSourcePreview.tsx index 5dbfb5a2..ac9aa69b 100644 --- a/src/app/components/previews/BiomeSourcePreview.tsx +++ b/src/app/components/previews/BiomeSourcePreview.tsx @@ -63,8 +63,6 @@ export const BiomeSourcePreview = ({ model, data, shown, version }: PreviewProps } }, [version, state, scale, seed, yOffset, shown, biomeColors, project]) - console.log(yOffset) - const changeScale = (newScale: number) => { newScale = Math.max(1, Math.round(newScale)) offset.current[0] = offset.current[0] * scale / newScale diff --git a/src/app/components/previews/LootTablePreview.tsx b/src/app/components/previews/LootTablePreview.tsx new file mode 100644 index 00000000..cdef22cd --- /dev/null +++ b/src/app/components/previews/LootTablePreview.tsx @@ -0,0 +1,79 @@ +import { DataModel } from '@mcschema/core' +import { useEffect, useRef, useState } from 'preact/hooks' +import { useLocale, useVersion } from '../../contexts/index.js' +import type { SlottedItem } from '../../previews/LootTable.js' +import { generateLootTable } from '../../previews/LootTable.js' +import { clamp, randomSeed } from '../../Utils.js' +import { Btn, BtnMenu, NumberInput } from '../index.js' +import { ItemDisplay } from '../ItemDisplay.jsx' +import type { PreviewProps } from './index.js' + +export const LootTablePreview = ({ data }: PreviewProps) => { + const { locale } = useLocale() + const { version } = useVersion() + const [seed, setSeed] = useState(randomSeed()) + const [luck, setLuck] = useState(0) + const [daytime, setDaytime] = useState(0) + const [weather, setWeather] = useState('clear') + const [mixItems, setMixItems] = useState(true) + const [advancedTooltips, setAdvancedTooltips] = useState(true) + const overlay = useRef(null) + + const [items, setItems] = useState([]) + + const table = DataModel.unwrapLists(data) + const state = JSON.stringify(table) + useEffect(() => { + const items = generateLootTable(table, { version, seed, luck, daytime, weather, stackMixer: mixItems ? 'container' : 'default' }) + setItems(items) + }, [version, seed, luck, daytime, weather, mixItems, state]) + + return <> +
+ Container background + {items.map(({ slot, item }) => +
+ +
+ )} +
+
+ +
e.stopPropagation()}> + {locale('preview.luck')} + +
+
e.stopPropagation()}> + {locale('preview.daytime')} + +
+
e.stopPropagation()}> + {locale('preview.weather')} + +
+ {setMixItems(!mixItems); e.stopPropagation()}} /> + {setAdvancedTooltips(!advancedTooltips); e.stopPropagation()}} /> +
+ setSeed(randomSeed())} /> +
+ +} + +const GUI_WIDTH = 176 +const GUI_HEIGHT = 81 +const SLOT_SIZE = 18 + +function slotStyle(slot: number) { + slot = clamp(slot, 0, 26) + const x = (slot % 9) * SLOT_SIZE + 7 + const y = (Math.floor(slot / 9)) * SLOT_SIZE + 20 + return { + left: `${x*100/GUI_WIDTH}%`, + top: `${y*100/GUI_HEIGHT}%`, + width: `${SLOT_SIZE*100/GUI_WIDTH}%`, + height: `${SLOT_SIZE*100/GUI_HEIGHT}%`, + } +} diff --git a/src/app/previews/BiomeSource.ts b/src/app/previews/BiomeSource.ts index eebbf9ab..3e401649 100644 --- a/src/app/previews/BiomeSource.ts +++ b/src/app/previews/BiomeSource.ts @@ -67,8 +67,6 @@ export async function getBiome(state: any, x: number, z: number, options: BiomeS const xx = Math.floor(centerX + ((x - 100) * quartStep)) const zz = Math.floor(centerZ + ((z - 100) * quartStep)) - console.log('get biome', options.y) - const { palette, data } = DEEPSLATE.fillBiomes(xx * 4, xx * 4 + 4, zz * 4, zz * 4 + 4, 1, options.y) const biome = palette.get(data[0])! diff --git a/src/app/previews/LootTable.ts b/src/app/previews/LootTable.ts new file mode 100644 index 00000000..0137e2bb --- /dev/null +++ b/src/app/previews/LootTable.ts @@ -0,0 +1,994 @@ +import type { Random } from 'deepslate' +import { LegacyRandom } from 'deepslate' +import type { VersionId } from '../services/Schemas.js' +import { clamp, deepClone, getWeightedRandom, isObject } from '../Utils.js' + +export interface Item { + id: string, + count: number, + tag?: any, +} + +export interface SlottedItem { + slot: number, + item: Item, +} + +type ItemConsumer = (item: Item) => 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: Item[] = [] + generateTable(lootTable, item => result.push(item), ctx) + const mixer = StackMixers[options.stackMixer] + return mixer(result, ctx) +} + +const SLOT_COUNT = 27 + +function fillContainer(items: Item[], ctx: LootContext): SlottedItem[] { + const slots = shuffle([...Array(SLOT_COUNT)].map((_, i) => i), ctx) + + const queue = items.filter(i => i.id !== 'minecraft:air' && i.count > 1) + items = items.filter(i => i.id !== 'minecraft: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.id !== 'minecraft:air' && item.count > 0) { + results.push({ slot, item }) + } + } + return results +} + +function assignSlots(items: Item[]): SlottedItem[] { + return items.map((item, i) => ({ slot: i, item })) +} + +function splitItem(item: Item, count: number): Item { + const splitCount = Math.min(count, item.count) + const other = deepClone(item) + 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:/, '') + switch (type) { + case 'item': + entryConsumer({ id: entry.name, count: 1 }) + break + case 'tag': + ctx.getItemTag(entry.name ?? '').forEach(tagEntry => { + entryConsumer({ id: tagEntry, count: 1 }) + }) + 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: Item, 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 (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.id === 'minecraft:book' + if (enchantments === undefined || enchantments.length === 0) { + enchantments = [...Enchantments.keys()] + .filter(e => { + const data = getEnchantmentData(e) + return data.discoverable && (isBook || data.canEnchant(item.id)) + }) + } + const id = enchantments[ctx.random.nextInt(enchantments.length)] + const data = getEnchantmentData(id) + const lvl = ctx.random.nextInt(data.maxLevel - data.minLevel + 1) + data.minLevel + enchantItem(item, { id, lvl }) + }, + enchant_with_levels: ({ levels, treasure }) => (item, ctx) => { + const enchants = selectEnchantments(ctx.random, item, computeInt(levels, ctx), treasure) + const isBook = item.id === 'minecraft:book' + if (isBook) { + item.id = 'minecraft:enchanted_book' + item.count = 1 + item.tag = {} + } + for (const enchant of enchants) { + enchantItem(item, enchant) + } + }, + limit_count: ({ limit }) => (item, ctx) => { + const { min, max } = prepareIntRange(limit, ctx) + item.count = clamp(item.count, min, max ) + }, + set_count: ({ count }) => (item, ctx) => { + item.count = computeInt(count, ctx) + }, + set_damage: ({ damage, add }) => (item, ctx) => { + const maxDamage = MaxDamageItems.get(item.id) + if (maxDamage) { + const oldDamage = add ? 1 - (item.tag?.Damage ?? 0) / maxDamage : 0 + const newDamage = 1 - clamp(computeFloat(damage, ctx) + oldDamage, 0, 1) + const finalDamage = Math.floor(newDamage * maxDamage) + item.tag = { ...item.tag, Damage: finalDamage } + } + }, + set_enchantments: ({ enchantments, add }) => (item, ctx) => { + Object.entries(enchantments).forEach(([id, level]) => { + const lvl = computeInt(level, ctx) + enchantItem(item, { id, lvl }, add) + }) + }, + set_lore: ({ lore, replace }) => (item) => { + const lines = lore.map((line: any) => JSON.stringify(line)) + const newLore = replace ? lines : [...(item.tag?.display?.Lore ?? []), ...lines] + item.tag = { ...item.tag, display: { ...item.tag?.display, Lore: newLore } } + }, + set_name: ({ name }) => (item) => { + const newName = JSON.stringify(name) + item.tag = { ...item.tag, display: { ...item.tag?.display, Name: newName } } + }, +} + +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 { + const type = condition.condition?.replace(/^minecraft:/, '') + return (LootConditions[type]?.(condition) ?? (() => true))(ctx) +} + +const LootConditions: Record LootCondition> = { + alternative: ({ terms }) => (ctx) => { + 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 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: Item, enchant: Enchant, additive?: boolean) { + if (!item.tag) { + item.tag = {} + } + const listKey = (item.id === 'minecraft:book') ? 'StoredEnchantments' : 'Enchantments' + if (!item.tag[listKey] || !Array.isArray(item.tag[listKey])) { + item.tag[listKey] = [] + } + const enchantments = item.tag[listKey] as any[] + let index = enchantments.findIndex((e: any) => e.id === enchant.id) + if (index !== -1) { + const oldEnch = enchantments[index] + oldEnch.lvl = Math.max(additive ? oldEnch.lvl + enchant.lvl : enchant.lvl, 0) + } else { + enchantments.push(enchant) + index = enchantments.length - 1 + } + if (enchantments[index].lvl === 0) { + enchantments.splice(index, 1) + } +} + +function selectEnchantments(random: Random, item: Item, levels: number, treasure: boolean): Enchant[] { + const enchantmentValue = EnchantmentItems.get(item.id) ?? 0 + if (enchantmentValue <= 0) { + 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 = [] + 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 => isEnchantCompatible(a.id, 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 +} + +function getEnchantWeight(ench: Enchant) { + return EnchantmentsRarityWeights.get(getEnchantmentData(ench.id)?.rarity ?? 'common') ?? 0 +} + +function getAvailableEnchantments(item: Item, levels: number, treasure: boolean): Enchant[] { + const result = [] + const isBook = item.id === 'minecraft:book' + + for (const id of Enchantments.keys()) { + const ench = getEnchantmentData(id)! + if ((!ench.treasure || treasure) && ench.discoverable && (ench.canEnchant(item.id) || 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: string, + lvl: number, +} + +function isEnchantCompatible(a: string, b: string) { + return a !== b && isEnchantCompatibleRaw(a, b) && isEnchantCompatibleRaw(b, a) +} + +function isEnchantCompatibleRaw(a: string, b: string) { + const ench = getEnchantmentData(a) + return ench?.isCompatible(b) +} + +export const MaxDamageItems = new Map(Object.entries({ + 'minecraft:carrot_on_a_stick': 25, + 'minecraft:warped_fungus_on_a_stick': 100, + 'minecraft:flint_and_steel': 64, + 'minecraft:elytra': 432, + 'minecraft:bow': 384, + 'minecraft:fishing_rod': 64, + 'minecraft:shears': 238, + 'minecraft:shield': 336, + 'minecraft:trident': 250, + 'minecraft:crossbow': 465, + + 'minecraft:leather_helmet': 11 * 5, + 'minecraft:leather_chestplate': 16 * 5, + 'minecraft:leather_leggings': 15 * 5, + 'minecraft:leather_boots': 13 * 5, + 'minecraft:chainmail_helmet': 11 * 15, + 'minecraft:chainmail_chestplate': 16 * 15, + 'minecraft:chainmail_leggings': 15 * 15, + 'minecraft:chainmail_boots': 13 * 15, + 'minecraft:iron_helmet': 11 * 15, + 'minecraft:iron_chestplate': 16 * 15, + 'minecraft:iron_leggings': 15 * 15, + 'minecraft:iron_boots': 13 * 15, + 'minecraft:diamond_helmet': 11 * 33, + 'minecraft:diamond_chestplate': 16 * 33, + 'minecraft:diamond_leggings': 15 * 33, + 'minecraft:diamond_boots': 13 * 33, + 'minecraft:golden_helmet': 11 * 7, + 'minecraft:golden_chestplate': 16 * 7, + 'minecraft:golden_leggings': 15 * 7, + 'minecraft:golden_boots': 13 * 7, + 'minecraft:netherite_helmet': 11 * 37, + 'minecraft:netherite_chestplate': 16 * 37, + 'minecraft:netherite_leggings': 15 * 37, + 'minecraft:netherite_boots': 13 * 37, + 'minecraft:turtle_helmet': 11 * 25, + + 'minecraft:wooden_sword': 59, + 'minecraft:wooden_shovel': 59, + 'minecraft:wooden_pickaxe': 59, + 'minecraft:wooden_axe': 59, + 'minecraft:wooden_hoe': 59, + 'minecraft:stone_sword': 131, + 'minecraft:stone_shovel': 131, + 'minecraft:stone_pickaxe': 131, + 'minecraft:stone_axe': 131, + 'minecraft:stone_hoe': 131, + 'minecraft:iron_sword': 250, + 'minecraft:iron_shovel': 250, + 'minecraft:iron_pickaxe': 250, + 'minecraft:iron_axe': 250, + 'minecraft:iron_hoe': 250, + 'minecraft:diamond_sword': 1561, + 'minecraft:diamond_shovel': 1561, + 'minecraft:diamond_pickaxe': 1561, + 'minecraft:diamond_axe': 1561, + 'minecraft:diamond_hoe': 1561, + 'minecraft:gold_sword': 32, + 'minecraft:gold_shovel': 32, + 'minecraft:gold_pickaxe': 32, + 'minecraft:gold_axe': 32, + 'minecraft:gold_hoe': 32, + 'minecraft:netherite_sword': 2031, + 'minecraft:netherite_shovel': 2031, + 'minecraft:netherite_pickaxe': 2031, + 'minecraft:netherite_axe': 2031, + 'minecraft:netherite_hoe': 2031, +})) + +const EnchantmentItems = new Map(Object.entries({ + 'minecraft:book': 1, + 'minecraft:fishing_rod': 1, + 'minecraft:trident': 1, + 'minecraft:bow': 1, + 'minecraft:crossbow': 1, + + 'minecraft:leather_helmet': 15, + 'minecraft:leather_chestplate': 15, + 'minecraft:leather_leggings': 15, + 'minecraft:leather_boots': 15, + 'minecraft:chainmail_helmet': 12, + 'minecraft:chainmail_chestplate': 12, + 'minecraft:chainmail_leggings': 12, + 'minecraft:chainmail_boots': 12, + 'minecraft:iron_helmet': 9, + 'minecraft:iron_chestplate': 9, + 'minecraft:iron_leggings': 9, + 'minecraft:iron_boots': 9, + 'minecraft:diamond_helmet': 10, + 'minecraft:diamond_chestplate': 10, + 'minecraft:diamond_leggings': 10, + 'minecraft:diamond_boots': 10, + 'minecraft:golden_helmet': 25, + 'minecraft:golden_chestplate': 25, + 'minecraft:golden_leggings': 25, + 'minecraft:golden_boots': 25, + 'minecraft:netherite_helmet': 15, + 'minecraft:netherite_chestplate': 15, + 'minecraft:netherite_leggings': 15, + 'minecraft:netherite_boots': 15, + 'minecraft:turtle_helmet': 15, + + 'minecraft:wooden_sword': 15, + 'minecraft:wooden_shovel': 15, + 'minecraft:wooden_pickaxe': 15, + 'minecraft:wooden_axe': 15, + 'minecraft:wooden_hoe': 15, + 'minecraft:stone_sword': 5, + 'minecraft:stone_shovel': 5, + 'minecraft:stone_pickaxe': 5, + 'minecraft:stone_axe': 5, + 'minecraft:stone_hoe': 5, + 'minecraft:iron_sword': 14, + 'minecraft:iron_shovel': 14, + 'minecraft:iron_pickaxe': 14, + 'minecraft:iron_axe': 14, + 'minecraft:iron_hoe': 14, + 'minecraft:diamond_sword': 10, + 'minecraft:diamond_shovel': 10, + 'minecraft:diamond_pickaxe': 10, + 'minecraft:diamond_axe': 10, + 'minecraft:diamond_hoe': 10, + 'minecraft:gold_sword': 22, + 'minecraft:gold_shovel': 22, + 'minecraft:gold_pickaxe': 22, + 'minecraft:gold_axe': 22, + 'minecraft:gold_hoe': 22, + 'minecraft:netherite_sword': 15, + 'minecraft:netherite_shovel': 15, + 'minecraft:netherite_pickaxe': 15, + 'minecraft:netherite_axe': 15, + 'minecraft:netherite_hoe': 15, +})) + +interface EnchantmentData { + id: string + rarity: 'common' | 'uncommon' | 'rare' | 'very_rare' + category: 'armor' | 'armor_feet' | 'armor_legs' | 'armor_chest' | 'armor_head' | 'weapon' | 'digger' | 'fishing_rod' | 'trident' | 'breakable' | 'bow' | 'wearable' | 'crossbow' | 'vanishable' + minLevel: number + maxLevel: number + minCost: (lvl: number) => number + maxCost: (lvl: number) => number + discoverable: boolean + treasure: boolean + curse: boolean + canEnchant: (id: string) => boolean + isCompatible: (other: string) => boolean +} + +export function getEnchantmentData(id: string): EnchantmentData { + const data = Enchantments.get(id) + const category = data?.category ?? 'armor' + return { + id, + rarity: data?.rarity ?? 'common', + category, + minLevel: data?.minLevel ?? 1, + maxLevel: data?.maxLevel ?? 1, + minCost: data?.minCost ?? ((lvl) => 1 + lvl * 10), + maxCost: data?.maxCost ?? ((lvl) => 6 + lvl * 10), + discoverable: data?.discoverable ?? true, + treasure: data?.treasure ?? false, + curse: data?.curse ?? false, + canEnchant: id => EnchantmentsCategories.get(category)!.includes(id), + isCompatible: data?.isCompatible ?? (() => true), + } +} + +const PROTECTION_ENCHANTS = ['minecraft:protection', 'minecraft:fire_protection', 'minecraft:blast_protection', 'minecraft:projectile_protection'] +const DAMAGE_ENCHANTS = ['minecraft:sharpness', 'minecraft:smite', 'minecraft:bane_of_arthropods'] + +const Enchantments = new Map(Object.entries>({ + 'minecraft:protection': { rarity: 'common', category: 'armor', maxLevel: 4, + minCost: lvl => 1 + (lvl - 1) * 11, + maxCost: lvl => 1 + (lvl - 1) * 11 + 11, + isCompatible: other => !PROTECTION_ENCHANTS.includes(other) }, + 'minecraft:fire_protection': { rarity: 'uncommon', category: 'armor', maxLevel: 4, + minCost: lvl => 10 + (lvl - 1) * 8, + maxCost: lvl => 10 + (lvl - 1) * 8 + 8, + isCompatible: other => !PROTECTION_ENCHANTS.includes(other) }, + 'minecraft:feather_falling': { rarity: 'uncommon', category: 'armor_feet', maxLevel: 4, + minCost: lvl => 5 + (lvl - 1) * 6, + maxCost: lvl => 5 + (lvl - 1) * 6 + 6 }, + 'minecraft:blast_protection': { rarity: 'rare', category: 'armor', maxLevel: 4, + minCost: lvl => 5 + (lvl - 1) * 8, + maxCost: lvl => 5 + (lvl - 1) * 8 + 8, + isCompatible: other => !PROTECTION_ENCHANTS.includes(other) }, + 'minecraft:projectile_protection': { rarity: 'uncommon', category: 'armor', maxLevel: 4, + minCost: lvl => 3 + (lvl - 1) * 6, + maxCost: lvl => 3 + (lvl - 1) * 6 + 6, + isCompatible: other => !PROTECTION_ENCHANTS.includes(other) }, + 'minecraft:respiration': { rarity: 'rare', category: 'armor_head', maxLevel: 3, + minCost: lvl => 10 * lvl, + maxCost: lvl => 10 * lvl + 30 }, + 'minecraft:aqua_affinity': { rarity: 'rare', category: 'armor_head', + minCost: () => 1, + maxCost: () => 40 }, + 'minecraft:thorns': { rarity: 'very_rare', category: 'armor_chest', maxLevel: 3, + minCost: lvl => 10 + 20 * (lvl - 1), + maxCost: lvl => 10 + 20 * (lvl - 1) + 50 }, + 'minecraft:depth_strider': { rarity: 'rare', category: 'armor_feet', maxLevel: 3, + minCost: lvl => 10 * lvl, + maxCost: lvl => 10 * lvl + 15, + isCompatible: other => other !== 'minecraft:frost_walker' }, + 'minecraft:frost_walker': { rarity: 'rare', category: 'armor_feet', maxLevel: 2, treasure: true, + minCost: lvl => 10 * lvl, + maxCost: lvl => 10 * lvl + 15, + isCompatible: other => other !== 'minecraft:depth_strider' }, + 'minecraft:binding_curse': { rarity: 'very_rare', category: 'wearable', treasure: true, curse: true, + minCost: () => 25, + maxCost: () => 50 }, + 'minecraft:soul_speed': { rarity: 'very_rare', category: 'armor_feet', maxLevel: 3, + discoverable: false, treasure: true, + minCost: lvl => 10 * lvl, + maxCost: lvl => 10 * lvl + 15 }, + 'minecraft:swift_sneak': { rarity: 'very_rare', category: 'armor_legs', maxLevel: 3, + discoverable: false, treasure: true, + minCost: lvl => 25 * lvl, + maxCost: lvl => 25 * lvl + 50 }, + 'minecraft:sharpness': { rarity: 'common', category: 'weapon', maxLevel: 5, + minCost: lvl => 1 + (lvl - 1) * 11, + maxCost: lvl => 1 + (lvl - 1) * 11 + 20, + isCompatible: other => !DAMAGE_ENCHANTS.includes(other) }, + 'minecraft:smite': { rarity: 'common', category: 'weapon', maxLevel: 5, + minCost: lvl => 5 + (lvl - 1) * 8, + maxCost: lvl => 5 + (lvl - 1) * 8 + 20, + isCompatible: other => !DAMAGE_ENCHANTS.includes(other) }, + 'minecraft:bane_of_arthropods': { rarity: 'common', category: 'weapon', maxLevel: 5, + minCost: lvl => 5 + (lvl - 1) * 8, + maxCost: lvl => 5 + (lvl - 1) * 8 + 20, + isCompatible: other => !DAMAGE_ENCHANTS.includes(other) }, + 'minecraft:knockback': { rarity: 'uncommon', category: 'weapon', maxLevel: 2, + minCost: lvl => 5 + 20 * (lvl - 1), + maxCost: lvl => 1 + lvl * 10 + 50 }, + 'minecraft:fire_aspect': { rarity: 'rare', category: 'weapon', maxLevel: 2, + minCost: lvl => 5 + 20 * (lvl - 1), + maxCost: lvl => 1 + lvl * 10 + 50 }, + 'minecraft:looting': { rarity: 'rare', category: 'weapon', maxLevel: 3, + minCost: lvl => 15 + (lvl - 1) * 9, + maxCost: lvl => 1 + lvl * 10 + 50, + isCompatible: other => other !== 'minecraft:silk_touch' }, + 'minecraft:sweeping': { rarity: 'rare', category: 'weapon', maxLevel: 3, + minCost: lvl => 5 + (lvl - 1) * 9, + maxCost: lvl => 5 + (lvl - 1) * 9 + 15 }, + 'minecraft:efficiency': { rarity: 'common', category: 'digger', maxLevel: 5, + minCost: lvl => 1 + 10 * (lvl - 1), + maxCost: lvl => 1 + lvl * 10 + 50, + canEnchant: id => id === 'minecraft:shears' || EnchantmentsCategories.get('digger')!.includes(id) }, + 'minecraft:silk_touch': { rarity: 'very_rare', category: 'digger', + minCost: () => 15, + maxCost: lvl => 1 + lvl * 10 + 50, + isCompatible: other => other !== 'minecraft:fortune' }, + 'minecraft:unbreaking': { rarity: 'uncommon', category: 'breakable', maxLevel: 3, + minCost: lvl => 5 + (lvl - 1) * 8, + maxCost: lvl => 1 + lvl * 10 + 50 }, + 'minecraft:fortune': { rarity: 'rare', category: 'digger', maxLevel: 3, + minCost: lvl => 15 + (lvl - 1) * 9, + maxCost: lvl => 1 + lvl * 10 + 50, + isCompatible: other => other !== 'minecraft:silk_touch' }, + 'minecraft:power': { rarity: 'common', category: 'bow', maxLevel: 5, + minCost: lvl => 1 + (lvl - 1) * 10, + maxCost: lvl => 1 + (lvl - 1) * 10 + 15 }, + 'minecraft:punch': { rarity: 'rare', category: 'bow', maxLevel: 2, + minCost: lvl => 12 + (lvl - 1) * 20, + maxCost: lvl => 12 + (lvl - 1) * 20 + 25 }, + 'minecraft:flame': { rarity: 'rare', category: 'bow', + minCost: () => 20, + maxCost: () => 50 }, + 'minecraft:infinity': { rarity: 'very_rare', category: 'bow', + minCost: () => 20, + maxCost: () => 50, + isCompatible: other => other !== 'minecraft:mending' }, + 'minecraft:luck_of_the_sea': { rarity: 'rare', category: 'fishing_rod', maxLevel: 3, + minCost: lvl => 15 + (lvl - 1) * 9, + maxCost: lvl => 1 + lvl * 10 + 50, + isCompatible: other => other !== 'minecraft:silk_touch' }, + 'minecraft:lure': { rarity: 'rare', category: 'fishing_rod', maxLevel: 3, + minCost: lvl => 15 + (lvl - 1) * 9, + maxCost: lvl => 1 + lvl * 10 + 50 }, + 'minecraft:loyalty': { rarity: 'uncommon', category: 'trident', maxLevel: 3, + minCost: lvl => 5 + lvl * 7, + maxCost: () => 50 }, + 'minecraft:impaling': { rarity: 'rare', category: 'trident', maxLevel: 5, + minCost: lvl => 1 + (lvl - 1) * 8, + maxCost: lvl => 1 + (lvl - 1) * 8 + 20 }, + 'minecraft:riptide': { rarity: 'rare', category: 'trident', maxLevel: 3, + minCost: lvl => 5 + lvl * 7, + maxCost: () => 50, + isCompatible: other => !['minecraft:riptide', 'minecraft:channeling'].includes(other) }, + 'minecraft:channeling': { rarity: 'very_rare', category: 'trident', + minCost: () => 25, + maxCost: () => 50 }, + 'minecraft:multishot': { rarity: 'rare', category: 'crossbow', + minCost: () => 20, + maxCost: () => 50, + isCompatible: other => other !== 'minecraft:piercing' }, + 'minecraft:quick_charge': { rarity: 'uncommon', category: 'crossbow', maxLevel: 3, + minCost: lvl => 12 + (lvl - 1) * 20, + maxCost: () => 50 }, + 'minecraft:piercing': { rarity: 'common', category: 'crossbow', maxLevel: 4, + minCost: lvl => 1 + (lvl - 1) * 10, + maxCost: () => 50, + isCompatible: other => other !== 'minecraft:multishot' }, + 'minecraft:mending': { rarity: 'rare', category: 'breakable', treasure: true, + minCost: lvl => lvl * 25, + maxCost: lvl => lvl * 25 + 50 }, + 'minecraft:vanishing_curse': { rarity: 'very_rare', category: 'vanishable', treasure: true, curse: true, + minCost: () => 25, + maxCost: () => 50 }, +})) + +const EnchantmentsRarityWeights = new Map(Object.entries({ + common: 10, + uncommon: 5, + rare: 2, + very_rare: 1, +})) + +const ARMOR_FEET = [ + 'minecraft:leather_boots', + 'minecraft:chainmail_boots', + 'minecraft:iron_boots', + 'minecraft:diamond_boots', + 'minecraft:golden_boots', + 'minecraft:netherite_boots', +] +const ARMOR_LEGS = [ + 'minecraft:leather_leggings', + 'minecraft:chainmail_leggings', + 'minecraft:iron_leggings', + 'minecraft:diamond_leggings', + 'minecraft:golden_leggings', + 'minecraft:netherite_leggings', +] +const ARMOR_CHEST = [ + 'minecraft:leather_chestplate', + 'minecraft:chainmail_chestplate', + 'minecraft:iron_chestplate', + 'minecraft:diamond_chestplate', + 'minecraft:golden_chestplate', + 'minecraft:netherite_chestplate', +] +const ARMOR_HEAD = [ + 'minecraft:leather_helmet', + 'minecraft:chainmail_helmet', + 'minecraft:iron_helmet', + 'minecraft:diamond_helmet', + 'minecraft:golden_helmet', + 'minecraft:netherite_helmet', + 'minecraft:turtle_helmet', +] +const ARMOR = [...ARMOR_FEET, ...ARMOR_LEGS, ...ARMOR_CHEST, ...ARMOR_HEAD] +const SWORD = [ + 'minecraft:wooden_sword', + 'minecraft:stone_sword', + 'minecraft:iron_sword', + 'minecraft:diamond_sword', + 'minecraft:gold_sword', + 'minecraft:netherite_sword', +] +const DIGGER = [ + 'minecraft:wooden_shovel', + 'minecraft:wooden_pickaxe', + 'minecraft:wooden_axe', + 'minecraft:wooden_hoe', + 'minecraft:stone_shovel', + 'minecraft:stone_pickaxe', + 'minecraft:stone_axe', + 'minecraft:stone_hoe', + 'minecraft:iron_shovel', + 'minecraft:iron_pickaxe', + 'minecraft:iron_axe', + 'minecraft:iron_hoe', + 'minecraft:diamond_shovel', + 'minecraft:diamond_pickaxe', + 'minecraft:diamond_axe', + 'minecraft:diamond_hoe', + 'minecraft:gold_shovel', + 'minecraft:gold_pickaxe', + 'minecraft:gold_axe', + 'minecraft:gold_hoe', + 'minecraft:netherite_shovel', + 'minecraft:netherite_pickaxe', + 'minecraft:netherite_axe', + 'minecraft:netherite_hoe', +] +const BREAKABLE = [...MaxDamageItems.keys()] +const WEARABLE = [ + ...ARMOR, + 'minecraft:elytra', + 'minecraft:carved_pumpkin', + 'minecraft:creeper_head', + 'minecraft:dragon_head', + 'minecraft:player_head', + 'minecraft:zombie_head', +] + +const EnchantmentsCategories = new Map(Object.entries({ + armor: ARMOR, + armor_feet: ARMOR_FEET, + armor_legs: ARMOR_LEGS, + armor_chest: ARMOR_CHEST, + armor_head: ARMOR_HEAD, + weapon: SWORD, + digger: DIGGER, + fishing_rod: ['minecraft:fishing_rod'], + trident: ['minecraft:trident'], + breakable: BREAKABLE, + bow: ['minecraft:bow'], + wearable: WEARABLE, + crossbow: ['minecraft:crossbow'], + vanishable: [...BREAKABLE, 'minecraft:compass'], +})) diff --git a/src/app/schema/renderHtml.tsx b/src/app/schema/renderHtml.tsx index 83568f87..b35cb201 100644 --- a/src/app/schema/renderHtml.tsx +++ b/src/app/schema/renderHtml.tsx @@ -139,7 +139,7 @@ const renderHtml: RenderHook = { let label: undefined | string | JSX.Element if (['loot_pool.entries.entry', 'loot_entry.alternatives.children.entry', 'loot_entry.group.children.entry', 'loot_entry.sequence.children.entry', 'function.set_contents.entries.entry'].includes(cPath.getContext().join('.'))) { if (isObject(cValue) && typeof cValue.type === 'string' && cValue.type.replace(/^minecraft:/, '') === 'item' && typeof cValue.name === 'string') { - label = + label = } } diff --git a/src/app/services/DataFetcher.ts b/src/app/services/DataFetcher.ts index bf003a9a..eacd7032 100644 --- a/src/app/services/DataFetcher.ts +++ b/src/app/services/DataFetcher.ts @@ -204,6 +204,16 @@ async function loadImage(src: string) { } */ +export async function fetchLanguage(versionId: VersionId, lang: string = 'en_us') { + const version = config.versions.find(v => v.id === versionId)! + await validateCache(version) + try { + return await cachedFetch>(`${mcmeta(version, 'assets')}/assets/minecraft/lang/${lang}.json`) + } catch (e) { + throw new Error(`Error occured while fetching language: ${message(e)}`) + } +} + export interface Change { group: string, version: string, diff --git a/src/app/services/Resources.ts b/src/app/services/Resources.ts index 22cfaacd..a8b82ad6 100644 --- a/src/app/services/Resources.ts +++ b/src/app/services/Resources.ts @@ -1,7 +1,7 @@ import type { BlockModelProvider, TextureAtlasProvider, UV } from 'deepslate/render' import { BlockModel, Identifier, ItemRenderer, TextureAtlas, upperPowerOfTwo } from 'deepslate/render' import { message } from '../Utils.js' -import { fetchResources } from './DataFetcher.js' +import { fetchLanguage, fetchResources } from './DataFetcher.js' import type { VersionId } from './Schemas.js' const Resources: Record> = {} @@ -99,3 +99,83 @@ export class ResourceManager implements BlockModelProvider, TextureAtlasProvider this.textureAtlas = new TextureAtlas(imageData, idMap) } } + +const Languages: Record | Promise>> = {} + +export async function getLanguage(version: VersionId) { + if (!Languages[version]) { + Languages[version] = (async () => { + try { + Languages[version] = await fetchLanguage(version) + return Languages[version] + } catch (e) { + console.error('Error: ', e) + throw new Error(`Cannot get language for version ${version}: ${message(e)}`) + } + })() + return Languages[version] + } + return Languages[version] +} + +export async function getTranslation(version: VersionId, key: string, params?: string[]) { + const lang = await getLanguage(version) + const str = lang[key] + if (!str) return null + 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/locales/en.json b/src/locales/en.json index 5138fdd3..b4bc585e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -127,11 +127,17 @@ "preview": "Visualize", "preview.auto_scroll": "Auto scroll", "preview.biome": "Biome", + "preview.daytime": "Daytime", + "preview.luck": "Luck", "preview.scale": "Scale", "preview.depth": "Depth", "preview.factor": "Factor", "preview.offset": "Offset", "preview.peaks": "Peaks", + "preview.weather": "Weather", + "preview.weather.clear": "Clear", + "preview.weather.rain": "Rain", + "preview.weather.thunder": "Thunder", "preview.width": "Width", "project.new": "New project", "project.cancel": "Cancel", diff --git a/src/styles/global.css b/src/styles/global.css index 5ffe44a9..fa0eb96b 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -132,6 +132,7 @@ body { min-height: 100vh; overflow-x: hidden; background-color: var(--background-1); + --full-width: calc(100vw - (100vw - 100%)); } header { @@ -454,6 +455,10 @@ main.has-preview { background-color: var(--nav-faded); display: block; cursor: crosshair; +} + +.popup-preview canvas, +.popup-preview .pixelated { image-rendering: -moz-crisp-edges; image-rendering: -webkit-crisp-edges; image-rendering: crisp-edges; @@ -524,6 +529,20 @@ main.has-project { padding-left: max(200px, 20vw); } +.preview-overlay { + height: min-content; + position: relative; +} + +.preview-overlay > img { + display: block; + width: 100%; +} + +.preview-overlay > div { + position: absolute; +} + .btn { display: flex; align-items: center; @@ -669,7 +688,8 @@ main.has-project { padding-right: 7px; } -.btn-input input { +.btn-input input, +.btn-input select { background: var(--background-1); color: var(--text-1); font-size: 17px; @@ -679,7 +699,8 @@ main.has-project { width: 100px; } -.btn-input.larger-input input { +.btn-input.larger-input input, +.btn-input.larger-input select { width: 200px; } @@ -688,7 +709,8 @@ main.has-project { padding-left: 11px; } -.btn-input.large-input input { +.btn-input.large-input input, +.btn-input.large-input select { width: 100%; height: 100%; } @@ -1164,29 +1186,149 @@ hr { } .item-display { - width: 32px; - height: 32px; + position: relative; + width: 100%; + height: 100%; display: flex; align-items: center; justify-content: center; } .item-display > img { - width: 26px; - position: relative; - image-rendering: pixelated; + width: 88.888%; + image-rendering: -moz-crisp-edges; + image-rendering: -webkit-crisp-edges; + image-rendering: crisp-edges; + image-rendering: pixelated; } -.item-display > svg { - width: 26px; - height: 20px; - position: relative; +.item-display > img.model { + image-rendering: auto; +} + +.item-display > svg:not(.item-count):not(.item-durability) { + width: 81.25%; + height: 62.5%; fill: var(--node-text-dimmed); } -.item-display > canvas { - width: 32px; - height: 32px; +.item-display > svg.item-count, +.item-display > svg.item-durability, +.item-display > .item-glint, +.item-display > .item-slot-overlay { + position: absolute; + right: 0; + bottom: 0; + width: 100%; + height: 100%; + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; +} + +.item-display > .item-glint, +.item-display > .item-slot-overlay { + left: 5.555%; + top: 5.555%; + width: 88.888%; + height: 88.888%; +} + +.item-display > .item-glint, +.item-display > .item-glint::after { + background: url(/images/glint.png) repeat; + filter: brightness(1.4) blur(1px) opacity(0.8); + animation: glint 20s linear 0s infinite; + background-size: 400%; + background-blend-mode: overlay; + -webkit-mask-image: var(--mask-image); + mask-image: var(--mask-image); + -webkit-mask-size: contain; + mask-size: contain; + image-rendering: -moz-crisp-edges; + image-rendering: -webkit-crisp-edges; + image-rendering: crisp-edges; + image-rendering: pixelated; +} + +.item-display > .item-glint::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + animation: glint2 30s linear 0s infinite; +} + +@keyframes glint { + from { + background-position: 0 0; + } + to { + background-position: -400% 400%; + } +} + +@keyframes glint2 { + from { + background-position: 100% 0; + } + to { + background-position: 500% 0; + } +} + +.item-display:hover > .item-slot-overlay { + background-color: #fff4; +} + +.item-tooltip { + padding: 3px 1px 1px 3px; + border: solid 4px #220044; + border-image-source: url(/images/tooltip.png); + border-image-slice: 2 fill; + border-image-outset: 2px; + image-rendering: -moz-crisp-edges; + image-rendering: -webkit-crisp-edges; + image-rendering: crisp-edges; + image-rendering: pixelated; +} + +.item-display > .item-tooltip { + display: none; + position: absolute; + margin: 4px; + pointer-events: none; + z-index: 5; +} + +.item-display:hover > .item-tooltip { + display: block; +} + +.item-display > .item-tooltip > :nth-child(1) { + margin-top: -2px; +} + +.item-display > .item-tooltip > :nth-child(2) { + margin-top: 4px; +} + +.text-component { + font-family: MinecraftSeven, sans-serif; + font-size: 20px; + position: relative; + white-space: nowrap; + line-height: 1.1 ; +} + +.text-component > .text-foreground { + position: absolute; + z-index: 1; + left: -2px; + top: -2px; } .file-view { @@ -1442,12 +1584,14 @@ hr { .sound-config input[type=range] { -webkit-appearance: none; + appearance: none; width: 100%; background: transparent; } .sound-config input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; + appearance: none; } .sound-config input[type=range]:focus { @@ -1456,6 +1600,7 @@ hr { .sound-config input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; + appearance: none; border: none; height: 16px; width: 16px; @@ -2191,7 +2336,7 @@ hr { } .popup-source { - width: 100vw; + width: var(--full-width); } .source { @@ -2199,7 +2344,7 @@ hr { } .popup-preview { - width: 100vw; + width: var(--full-width); height: unset; bottom: 0; background-color: transparent; @@ -2223,3 +2368,8 @@ hr { display: none; } } + +@font-face { + font-family: "MinecraftSeven"; + src: url("/fonts/seven.ttf") format("truetype"); +} diff --git a/src/styles/nodes.css b/src/styles/nodes.css index 0699d33b..4ba722b0 100644 --- a/src/styles/nodes.css +++ b/src/styles/nodes.css @@ -108,6 +108,11 @@ background-color: var(--node-background-label); } +.node-header > label > .item-display { + width: 32px; + height: 32px; +} + .node-header > input { font-size: 18px; padding-left: 9px; diff --git a/vite.config.js b/vite.config.js index cbcf5c15..01a958a6 100644 --- a/vite.config.js +++ b/vite.config.js @@ -83,8 +83,6 @@ export default defineConfig({ preact(), viteStaticCopy({ targets: [ - { src: 'src/.nojekyll', dest: '' }, - { src: 'src/sitemap.txt', dest: '' }, { src: 'src/styles/giscus.css', dest: 'assets' }, { src: 'src/styles/giscus-burn.css', dest: 'assets' }, { src: 'src/guides/*', dest: 'guides' },