From 154889ea6696dcde447fc3e92518b08d4506cd48 Mon Sep 17 00:00:00 2001 From: Niklas Widmann Date: Thu, 12 Oct 2023 22:04:55 +0200 Subject: [PATCH] common emitter logic & basic loot loader/emitter --- src/emit/custom.ts | 62 ++++------------ src/emit/index.ts | 7 ++ src/emit/loot.ts | 64 ++++++++++++++++ src/emit/recipe.ts | 105 +++++++++++---------------- src/emit/ruled.ts | 47 ++++++++++++ src/error.ts | 6 +- src/loader/loot.ts | 43 +++++++++++ src/loader/pack.ts | 22 +++++- src/loader/recipe.ts | 9 +-- src/parser/recipe/create/assembly.ts | 4 +- src/parser/recipe/index.ts | 2 +- src/rule/index.ts | 14 ++++ src/rule/lootTable.ts | 17 ++++- src/rule/recipe.ts | 27 +++---- src/schema/loot.ts | 92 +++++++++++++++++++++++ test/__snapshots__/loot.test.ts.snap | 27 +++++++ test/loot.test.ts | 64 ++++++++++++++++ 17 files changed, 472 insertions(+), 140 deletions(-) create mode 100644 src/schema/loot.ts create mode 100644 test/__snapshots__/loot.test.ts.snap create mode 100644 test/loot.test.ts diff --git a/src/emit/custom.ts b/src/emit/custom.ts index 269c542..c1344e5 100644 --- a/src/emit/custom.ts +++ b/src/emit/custom.ts @@ -1,68 +1,34 @@ -import Rule from '../rule' import { Acceptor } from '@pssbletrngle/pack-resolver' -import { Recipe } from '../parser/recipe' import { toJson } from '../textHelper' -import { RecipeDefinition } from '../schema/recipe' -import { createId, Id, IdInput } from '../common/id' +import { createId, IdInput } from '../common/id' import Registry from '../common/registry' -import { LootTable } from '../loader/loot' +import { PathProvider } from './index' -export interface RegistryProvider { - forEach(consumer: (recipe: T, id: Id) => void): void -} - -export default abstract class RuledEmitter> { +export default class CustomEmitter { + constructor(private readonly pathProvider: PathProvider) {} - protected constructor(private readonly provider: RegistryProvider) { + protected addCustom(id: IdInput, value: TEntry) { + this.customEntries.set(createId(id), value) } - private customEntries = new Registry() - private rulesArray: TRule[] = [] - - protected get rules(): ReadonlyArray { - return this.rulesArray - } clear() { - this.rulesArray = [] - } - - protected addRule(rule: TRule) { - this.rulesArray.push(rule) + this.customEntries.clear() } - protected addCustom( - id: IdInput, - value: TEntry - ) { + add(id: IdInput, value: TEntry) { this.customEntries.set(createId(id), value) } - private async modify(acceptor: Acceptor) { - this.provider.forEach((recipe, id) => { - if (this.customRecipe.has(id)) return - - const path = this.recipePath(id) - - const rules = this.rules.filter(it => it.matches(id, recipe, this.logger)) - if (rules.length === 0) return - - const modified = rules.reduce((previous, rule) => previous && rule.modify(previous), recipe) - - acceptor(path, toJson(modified?.toDefinition() ?? RecipeEmitter.EMPTY_RECIPE)) - }) - } - - private async create(acceptor: Acceptor) { - this.customRecipe.forEach((recipe, id) => { - const path = this.recipePath(id) - acceptor(path, toJson(recipe)) + async emit(acceptor: Acceptor) { + this.customEntries.forEach((entry, id) => { + const path = this.pathProvider(id) + acceptor(path, toJson(entry)) }) } - async emit(acceptor: Acceptor) { - await Promise.all([this.modifyRecipes(acceptor), this.createRecipes(acceptor)]) + has(id: IdInput) { + return this.customEntries.has(id) } - } diff --git a/src/emit/index.ts b/src/emit/index.ts index e69de29..7ce7a82 100644 --- a/src/emit/index.ts +++ b/src/emit/index.ts @@ -0,0 +1,7 @@ +import { Id } from '../common/id' + +export interface RegistryProvider { + forEach(consumer: (recipe: T, id: Id) => void): void +} + +export type PathProvider = (id: Id) => string diff --git a/src/emit/loot.ts b/src/emit/loot.ts index e69de29..bbd0249 100644 --- a/src/emit/loot.ts +++ b/src/emit/loot.ts @@ -0,0 +1,64 @@ +import { Logger } from '../logger' +import TagsLoader from '../loader/tags' +import { LootTable, LootTableSchema } from '../schema/loot' +import { Acceptor } from '@pssbletrngle/pack-resolver' +import RuledEmitter from './ruled' +import CustomEmitter from './custom' +import { Id, IdInput } from '../common/id' +import LootTableRule from '../rule/lootTable' +import { RegistryProvider } from './index' +import { Predicate } from '../common/ingredient' + +export const EMPTY_LOOT_TABLE: LootTable = { + type: 'minecraft:empty', + pools: [], +} + +type LootTableTest = Predicate + +export interface LootRules { + // replace(test: IngredientTest, to: Item | ItemTag): void + + addLootTable(id: IdInput, value: LootTable): void + + removeLootTable(test: LootTableTest): void +} + +export default class LootTableEmitter implements LootRules { + private readonly custom = new CustomEmitter(this.lootPath) + + private readonly ruled = new RuledEmitter( + this.logger, + this.lootTables, + this.lootPath, + EMPTY_LOOT_TABLE, + id => this.custom.has(id) + ) + + constructor( + private readonly logger: Logger, + private readonly lootTables: RegistryProvider, + private readonly tags: TagsLoader + ) {} + + private lootPath(id: Id) { + return `data/${id.namespace}/loot_tables/${id.path}.json` + } + + async emit(acceptor: Acceptor) { + await Promise.all([this.ruled.emit(acceptor), this.custom.emit(acceptor)]) + } + + addLootTable(id: IdInput, value: LootTable): void { + this.custom.add(id, LootTableSchema.parse(value)) + } + + removeLootTable(test: LootTableTest): void { + this.ruled.addRule(new LootTableRule([test], () => null)) + } + + clear() { + this.custom.clear() + this.ruled.clear() + } +} diff --git a/src/emit/recipe.ts b/src/emit/recipe.ts index 64af208..eb55491 100644 --- a/src/emit/recipe.ts +++ b/src/emit/recipe.ts @@ -1,6 +1,3 @@ -import { RecipeRegistry } from '../loader/recipe' -import { Acceptor } from '@pssbletrngle/pack-resolver' -import { toJson } from '../textHelper' import { CommonTest, Ingredient, @@ -12,12 +9,15 @@ import { import RecipeRule from '../rule/recipe' import { Logger } from '../logger' import TagsLoader from '../loader/tags' -import { createId, Id, IdInput, NormalizedId } from '../common/id' +import { Id, IdInput, NormalizedId } from '../common/id' import { Recipe } from '../parser/recipe' import { RecipeDefinition } from '../schema/recipe' -import Registry from '../common/registry' import { resolveIDTest } from '../common/predicates' import { Result } from '../common/result' +import RuledEmitter from './ruled' +import { RegistryProvider } from './index' +import CustomEmitter from './custom' +import { Acceptor } from '@pssbletrngle/pack-resolver' type RecipeTest = Readonly<{ id?: CommonTest @@ -39,67 +39,47 @@ export interface RecipeRules { removeRecipe(test: RecipeTest): void } -export default class RecipeEmitter implements RecipeRules { - // TODO conditions - static readonly EMPTY_RECIPE: RecipeDefinition = { - type: 'noop', - conditions: [ - { - type: 'forge:false', +export const EMPTY_RECIPE: RecipeDefinition = { + type: 'noop', + conditions: [ + { + type: 'forge:false', + }, + ], + 'fabric:load_conditions': [ + { + condition: 'fabric:not', + value: { + condition: 'fabric:all_mods_loaded', + values: ['minecraft'], }, - ], - 'fabric:load_conditions': [ - { - condition: 'fabric:not', - value: { - condition: 'fabric:all_mods_loaded', - values: ['minecraft'], - }, - }, - ], - } + }, + ], +} - private rules: RecipeRule[] = [] - private customRecipe = new Registry() +export default class RecipeEmitter implements RecipeRules { + private readonly custom = new CustomEmitter(this.recipePath) + + private readonly ruled = new RuledEmitter( + this.logger, + this.registry, + this.recipePath, + EMPTY_RECIPE, + id => this.custom.has(id) + ) constructor( private readonly logger: Logger, - private readonly registry: RecipeRegistry, + private readonly registry: RegistryProvider, private readonly tags: TagsLoader ) {} - clear() { - this.rules = [] - } - private recipePath(id: Id) { return `data/${id.namespace}/recipe/${id.path}.json` } - private async modifyRecipes(acceptor: Acceptor) { - this.registry.forEach((recipe, id) => { - if (this.customRecipe.has(id)) return - - const path = this.recipePath(id) - - const rules = this.rules.filter(it => it.matches(id, recipe, this.logger)) - if (rules.length === 0) return - - const modified = rules.reduce((previous, rule) => previous && rule.modify(previous), recipe) - - acceptor(path, toJson(modified?.toDefinition() ?? RecipeEmitter.EMPTY_RECIPE)) - }) - } - - private async createRecipes(acceptor: Acceptor) { - this.customRecipe.forEach((recipe, id) => { - const path = this.recipePath(id) - acceptor(path, toJson(recipe)) - }) - } - async emit(acceptor: Acceptor) { - await Promise.all([this.modifyRecipes(acceptor), this.createRecipes(acceptor)]) + await Promise.all([this.ruled.emit(acceptor), this.custom.emit(acceptor)]) } resolveIngredientTest(test: IngredientTest) { @@ -119,21 +99,17 @@ export default class RecipeEmitter implements RecipeRules { return { recipe, ingredient, result } } - addRule(rule: RecipeRule) { - this.rules.push(rule) - } - addRecipe>( id: IdInput, value: TDefinition | TRecipe ) { - if (value instanceof Recipe) this.addRecipe(id, value.toDefinition()) - else this.customRecipe.set(createId(id), value) + if (value instanceof Recipe) this.custom.add(id, value.toJSON()) + else this.custom.add(id, value) } removeRecipe(test: RecipeTest) { const recipePredicates = this.resolveRecipeTest(test) - this.addRule( + this.ruled.addRule( new RecipeRule(recipePredicates.recipe, recipePredicates.ingredient, recipePredicates.result, () => null) ) } @@ -141,7 +117,7 @@ export default class RecipeEmitter implements RecipeRules { replaceResult(test: IngredientTest, value: Result, additionalTest: RecipeTest = {}) { const predicate = this.resolveIngredientTest(test) const recipePredicates = this.resolveRecipeTest(additionalTest) - this.addRule( + this.ruled.addRule( new RecipeRule( recipePredicates.recipe, recipePredicates.ingredient, @@ -154,7 +130,7 @@ export default class RecipeEmitter implements RecipeRules { replaceIngredient(test: IngredientTest, value: Ingredient, additionalTest: RecipeTest = {}) { const predicate = this.resolveIngredientTest(test) const recipePredicates = this.resolveRecipeTest(additionalTest) - this.addRule( + this.ruled.addRule( new RecipeRule( recipePredicates.recipe, [predicate, ...recipePredicates.ingredient], @@ -163,4 +139,9 @@ export default class RecipeEmitter implements RecipeRules { ) ) } + + clear() { + this.custom.clear() + this.ruled.clear() + } } diff --git a/src/emit/ruled.ts b/src/emit/ruled.ts index e69de29..40e7961 100644 --- a/src/emit/ruled.ts +++ b/src/emit/ruled.ts @@ -0,0 +1,47 @@ +import Rule from '../rule' +import { Acceptor } from '@pssbletrngle/pack-resolver' +import { toJson } from '../textHelper' +import Registry from '../common/registry' +import { PathProvider, RegistryProvider } from './index' +import { Logger } from '../logger' +import { Id } from '../common/id' + +export default class RuledEmitter> { + constructor( + private readonly logger: Logger, + private readonly provider: RegistryProvider, + private readonly pathProvider: PathProvider, + private readonly emptyValue: unknown, + private readonly shouldSkip: (id: Id) => boolean = () => true + ) {} + + private customEntries = new Registry() + private rulesArray: TRule[] = [] + + protected get rules(): ReadonlyArray { + return this.rulesArray + } + + clear() { + this.rulesArray = [] + } + + addRule(rule: TRule) { + this.rulesArray.push(rule) + } + + async emit(acceptor: Acceptor) { + this.provider.forEach((recipe, id) => { + if (this.shouldSkip(id)) return + + const path = this.pathProvider(id) + + const rules = this.rules.filter(it => it.matches(id, recipe, this.logger)) + if (rules.length === 0) return + + const modified = rules.reduce((previous, rule) => previous && rule.modify(previous), recipe) + + acceptor(path, toJson(modified ?? this.emptyValue)) + }) + } +} diff --git a/src/error.ts b/src/error.ts index ff60e8c..752145a 100644 --- a/src/error.ts +++ b/src/error.ts @@ -1,4 +1,5 @@ import { Logger } from './logger' +import { ZodError } from 'zod' export class IllegalShapeError extends Error { constructor(message: string, readonly input?: unknown) { @@ -15,7 +16,10 @@ export function tryCatching(logger: Logger | undefined, run: () => T): T | nu return null } - // TODO catch zod errors? + if (error instanceof ZodError) { + logger?.warn(`unknown shape`, error) + return null + } throw error } diff --git a/src/loader/loot.ts b/src/loader/loot.ts index e69de29..9954c94 100644 --- a/src/loader/loot.ts +++ b/src/loader/loot.ts @@ -0,0 +1,43 @@ +import { Logger } from '../logger' +import { Acceptor } from '@pssbletrngle/pack-resolver' +import { encodeId, Id } from '../common/id' +import { fromJson } from '../textHelper' +import Registry from '../common/registry' +import { tryCatching } from '../error' +import { RegistryProvider } from '../emit' +import { LootTable, LootTableSchema } from '../schema/loot' + +export default class LootTableLoader implements RegistryProvider { + private readonly registry = new Registry() + + constructor(private readonly logger: Logger) {} + + private parse(raw: unknown, id: Id): LootTable | null { + const logger = this.logger.group(`error parsing loot table ${encodeId(id)}`) + return tryCatching(logger, () => { + return LootTableSchema.parse(raw) + }) + } + + readonly accept: Acceptor = (path, content) => { + // TODO extract common logic + const match = /data\/(?[\w-]+)\/loot_tables\/(?[\w-/]+).json/.exec(path) + if (!match?.groups) return false + + const { namespace, rest } = match.groups + const id: Id = { namespace, path: rest } + + const json = fromJson(content.toString()) + + const parsed = this.parse(json, id) + if (!parsed) return false + + this.registry.set(id, parsed) + + return true + } + + forEach(consumer: (recipe: LootTable, id: Id) => void): void { + this.registry.forEach(consumer) + } +} diff --git a/src/loader/pack.ts b/src/loader/pack.ts index ec95239..b288a64 100644 --- a/src/loader/pack.ts +++ b/src/loader/pack.ts @@ -7,15 +7,19 @@ import TagsLoader from './tags' import RecipeEmitter, { RecipeRules } from '../emit/recipe' import TagEmitter, { TagRules } from '../emit/tags' import { IngredientTest } from '../common/ingredient' +import LootTableLoader from './loot' +import LootTableEmitter, { LootRules } from '../emit/loot' export default class PackLoader implements Loader { constructor(private readonly logger: Logger) {} private readonly tagLoader = new TagsLoader(this.logger) private readonly recipesLoader = new RecipeLoader(this.logger) + private readonly lootLoader = new LootTableLoader(this.logger) - private readonly recipeEmitter = new RecipeEmitter(this.logger, this.recipesLoader, this.tagLoader) private readonly tagEmitter = new TagEmitter(this.logger, this.tagLoader) + private readonly recipeEmitter = new RecipeEmitter(this.logger, this.recipesLoader, this.tagLoader) + private readonly lootEmitter = new LootTableEmitter(this.logger, this.lootLoader, this.tagLoader) registerRegistry(key: string) { this.tagLoader.registerRegistry(key) @@ -25,12 +29,16 @@ export default class PackLoader implements Loader { return this.tagLoader.registry(key) } + get tags(): TagRules { + return this.tagEmitter + } + get recipes(): RecipeRules { return this.recipeEmitter } - get tags(): TagRules { - return this.tagEmitter + get loot(): LootRules { + return this.lootEmitter } resolveIngredientTest(test: IngredientTest) { @@ -40,6 +48,7 @@ export default class PackLoader implements Loader { private acceptors: Record = { 'data/*/tags/**/*.json': this.tagLoader.accept, 'data/*/recipes/**/*.json': this.recipesLoader.accept, + 'data/*/loot_tables/**/*.json': this.lootLoader.accept, } async loadFrom(resolver: IResolver) { @@ -55,11 +64,16 @@ export default class PackLoader implements Loader { clear() { this.recipesLoader.clear() this.recipeEmitter.clear() + this.lootEmitter.clear() this.tagEmitter.clear() } async emit(acceptor: Acceptor) { - await Promise.all([this.recipeEmitter.emit(acceptor), this.tagEmitter.emit(acceptor)]) + await Promise.all([ + this.recipeEmitter.emit(acceptor), + this.lootEmitter.emit(acceptor), + this.tagEmitter.emit(acceptor), + ]) } async run(from: IResolver, to: Acceptor) { diff --git a/src/loader/recipe.ts b/src/loader/recipe.ts index 740df69..03e3ead 100644 --- a/src/loader/recipe.ts +++ b/src/loader/recipe.ts @@ -26,12 +26,9 @@ import ApothecaryRecipeParser from '../parser/recipe/botania/apothecary' import Registry from '../common/registry' import TerraPlateRecipeParser from '../parser/recipe/botania/terraPlate' import PureDaisyRecipeParser from '../parser/recipe/botania/pureDaisy' +import { RegistryProvider } from '../emit' -export interface RecipeRegistry { - forEach(consumer: (recipe: Recipe, id: Id) => void): void -} - -export default class RecipeLoader implements RecipeRegistry { +export default class RecipeLoader implements RegistryProvider { private readonly recipeParsers = new Map>() private readonly ignoredRecipeTypes = new Set() @@ -146,7 +143,7 @@ export default class RecipeLoader implements RecipeRegistry { this.recipeParsers.set(recipeType, parser) } - accept: Acceptor = (path, content) => { + readonly accept: Acceptor = (path, content) => { const match = /data\/(?[\w-]+)\/recipes\/(?[\w-/]+).json/.exec(path) if (!match?.groups) return false diff --git a/src/parser/recipe/create/assembly.ts b/src/parser/recipe/create/assembly.ts index e62d1f3..e6693f5 100644 --- a/src/parser/recipe/create/assembly.ts +++ b/src/parser/recipe/create/assembly.ts @@ -38,7 +38,7 @@ class AssemblyRecipe extends Recipe { ...this.definition, ingredient: replaceOrKeep(from, to, this.definition.ingredient), transitionalItem: replaceOrKeep(from, to, this.definition.ingredient), - sequence: this.sequence.map(it => it.replaceIngredient(from, to).toDefinition()), + sequence: this.sequence.map(it => it.replaceIngredient(from, to).toJSON()), }) } @@ -46,7 +46,7 @@ class AssemblyRecipe extends Recipe { return new AssemblyRecipe({ ...this.definition, results: this.definition.results.map(replace(from, to)), - sequence: this.sequence.map(it => it.replaceResult(from, to).toDefinition()), + sequence: this.sequence.map(it => it.replaceResult(from, to).toJSON()), }) } } diff --git a/src/parser/recipe/index.ts b/src/parser/recipe/index.ts index f57341e..c0ef698 100644 --- a/src/parser/recipe/index.ts +++ b/src/parser/recipe/index.ts @@ -24,7 +24,7 @@ export abstract class Recipe, to: ResultInput): Recipe - toDefinition(): TDefinition { + toJSON(): TDefinition { return this.definition } } diff --git a/src/rule/index.ts b/src/rule/index.ts index e69de29..b1f3dfe 100644 --- a/src/rule/index.ts +++ b/src/rule/index.ts @@ -0,0 +1,14 @@ +import { Id } from '../common/id' +import { Logger } from '../logger' + +export type Modifier = (recipe: T) => T | null + +export default abstract class Rule { + protected constructor(private readonly modifier: Modifier) {} + + abstract matches(id: Id, recipe: T, logger: Logger): boolean + + modify(value: T) { + return this.modifier(value) + } +} diff --git a/src/rule/lootTable.ts b/src/rule/lootTable.ts index 579ab80..e6d6835 100644 --- a/src/rule/lootTable.ts +++ b/src/rule/lootTable.ts @@ -1 +1,16 @@ -export default class LootRule {} +import Rule, { Modifier } from './index' +import { LootTable } from '../schema/loot' +import { encodeId, Id } from '../common/id' +import { Logger } from '../logger' +import { Predicate } from '../common/ingredient' + +export default class LootTableRule extends Rule { + constructor(private readonly idTests: Predicate[], modifier: Modifier) { + super(modifier) + } + + matches(id: Id, recipe: LootTable, logger: Logger): boolean { + const prefixed = logger.group(encodeId(id)) + return this.idTests.every(test => test(id, prefixed)) + } +} diff --git a/src/rule/recipe.ts b/src/rule/recipe.ts index 70d891f..6c57ae2 100644 --- a/src/rule/recipe.ts +++ b/src/rule/recipe.ts @@ -1,28 +1,25 @@ -import { IngredientInput, Predicate } from '../../common/ingredient' -import { Recipe } from '../../parser/recipe' -import { encodeId, Id } from '../../common/id' -import { Logger } from '../../logger' +import { IngredientInput, Predicate } from '../common/ingredient' +import { Recipe } from '../parser/recipe' +import { encodeId, Id } from '../common/id' +import { Logger } from '../logger' +import Rule, { Modifier } from './index' -export type RecipeModifier = (recipe: Recipe) => Recipe | null - -export default class RecipeRule { +export default class RecipeRule extends Rule { constructor( - private readonly recipeTests: Predicate[], + private readonly idsTests: Predicate[], private readonly ingredientTests: Predicate[], private readonly resultTests: Predicate[], - private readonly modifier: RecipeModifier - ) {} + modifier: Modifier + ) { + super(modifier) + } matches(id: Id, recipe: Recipe, logger: Logger): boolean { const prefixed = logger.group(encodeId(id)) return ( - this.recipeTests.every(test => test(id, prefixed)) && + this.idsTests.every(test => test(id, prefixed)) && this.ingredientTests.every(test => recipe.getIngredients().some(it => test(it, prefixed))) && this.resultTests.every(test => recipe.getResults().some(it => test(it, prefixed))) ) } - - modify(recipe: Recipe) { - return this.modifier(recipe) - } } diff --git a/src/schema/loot.ts b/src/schema/loot.ts new file mode 100644 index 0000000..34bd921 --- /dev/null +++ b/src/schema/loot.ts @@ -0,0 +1,92 @@ +import zod from 'zod' + +const NumberProviderSchema = zod.union([ + zod.number(), + zod.object({ + type: zod.string(), + }), +]) + +const LootConditionSchema = zod.object({ + condition: zod.string(), +}) + +const LootFunctionSchema = zod.object({ + function: zod.string(), +}) + +const LootEntryBaseSchema = zod + .object({ + type: zod.string(), + conditions: zod.array(LootConditionSchema).optional(), + functions: zod.array(LootFunctionSchema).optional(), + }) + .passthrough() + +export type LootEntryBase = zod.infer + +export const LootEntryAlternativeSchema = zod.object({ + type: zod.literal('minecraft:alternatives'), + children: zod.array(LootEntryBaseSchema), +}) + +export type LootEntryAlternative = zod.infer + +export const LootEntryItemSchema = zod.object({ + type: zod.literal('minecraft:item'), + name: zod.string(), +}) + +export type LootEntryItem = zod.infer + +export const LootEntryTagSchema = zod.object({ + type: zod.literal('minecraft:tag'), + name: zod.string(), + expand: zod.boolean().optional(), +}) + +export type LootEntryTag = zod.infer + +export const LootEntryEmptySchema = zod.object({ + type: zod.literal('minecraft:empty'), +}) + +export type LootEntryEmpty = zod.infer + +export const LootEntryReferenceSchema = zod.object({ + type: zod.literal('minecraft:loot_table'), + name: zod.string(), +}) + +export type LootEntryReference = zod.infer + +export const LootEntrySchema = LootEntryBaseSchema.and( + zod.discriminatedUnion('type', [ + LootEntryAlternativeSchema, + LootEntryItemSchema, + LootEntryTagSchema, + LootEntryReferenceSchema, + LootEntryEmptySchema, + ]) +) + +export type LootEntry = zod.infer + +export const LootPoolSchema = zod.object({ + rolls: NumberProviderSchema, + bonus_rolls: NumberProviderSchema.optional(), + entries: zod.array(LootEntryBaseSchema), +}) + +export type LootPool = zod.infer + +export const LootTableSchema = zod.object({ + type: zod.string(), + pools: zod.array(LootPoolSchema).default([]), +}) + +export type LootTable = zod.infer + +export function extendLootEntry(base: LootEntryBase): LootEntry { + return LootEntrySchema.parse(base) +} diff --git a/test/__snapshots__/loot.test.ts.snap b/test/__snapshots__/loot.test.ts.snap new file mode 100644 index 0000000..0c1585f --- /dev/null +++ b/test/__snapshots__/loot.test.ts.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`creates custom loot tables: parsed loot table 1`] = ` +{ + "pools": [ + { + "entries": [ + { + "children": [ + { + "name": "minecraft:diamond", + "type": "minecraft:item", + }, + ], + "type": "minecraft:alternatives", + }, + { + "name": "minecraft:logs", + "type": "minecraft:tag", + }, + ], + "rolls": 4, + }, + ], + "type": "minecraft:block", +} +`; diff --git a/test/loot.test.ts b/test/loot.test.ts new file mode 100644 index 0000000..40202f3 --- /dev/null +++ b/test/loot.test.ts @@ -0,0 +1,64 @@ +import PackLoader from '../src/loader/pack' +import createTestLogger from './mock/TestLogger' +import createTestResolver from './mock/TestResolver' +import createTestAcceptor from './mock/TestAcceptor' +import { + LootEntryAlternativeSchema, + LootEntryItemSchema, + LootEntryTagSchema, + LootTableSchema, +} from '../src/schema/loot' + +const logger = createTestLogger() +const loader = new PackLoader(logger) + +beforeAll(async () => { + const resolver = createTestResolver({ include: ['data/*/loot_tables/**/*.json'] }) + await loader.loadFrom(resolver) +}, 10_000) + +afterEach(() => { + loader.clear() +}) + +describe('loading of loot tables', () => { + it('loads loot tables without errors', async () => { + expect(logger.warn).not.toHaveBeenCalled() + expect(logger.error).not.toHaveBeenCalled() + }) +}) + +it('creates custom loot tables', async () => { + const acceptor = createTestAcceptor() + + const lootTable = LootTableSchema.parse({ + type: 'minecraft:block', + pools: [ + { + rolls: 4, + entries: [ + LootEntryAlternativeSchema.parse({ + type: 'minecraft:alternatives', + children: [ + LootEntryItemSchema.parse({ + type: 'minecraft:item', + name: 'minecraft:diamond', + }), + ], + }), + LootEntryTagSchema.parse({ + type: 'minecraft:tag', + name: 'minecraft:logs', + }), + ], + }, + ], + }) + + loader.loot.addLootTable('example:custom', lootTable) + + await loader.emit(acceptor) + + expect(acceptor.jsonAt('data/example/loot_tables/custom.json')).toMatchSnapshot('parsed loot table') + expect(acceptor.jsonAt('data/example/loot_tables/custom.json')).toMatchObject(lootTable) +})