.jar`
+(e.g. `KubeJS Thaumcraft-1.0.0+21.1.234.jar`)
+
+Compile dependencies (local jars, paths editable in `build.gradle`):
+- KubeJS: `F:/downloads/kubejs-neoforge-2101.7.2-build.368.jar`
+- Rhino: `F:/downloads/rhino-2101.2.7-build.81.jar` (KubeJS script engine)
+- Thaumcraft: `modules/thaumicenergistics-neo/library/thaumcraft-0.2.2.34-port.1.jar`
+
+> Adjust the `files(...)` paths in the `dependencies` block if your local layout differs.
+
+## Script Usage
+
+Put scripts into `kubejs/server_scripts/` and **restart the game** (see Note #1).
+
+### 1. Infusion Recipe
+
+```js
+ServerEvents.recipes(event => {
+ event.recipes.thaumcraft.infusion(
+ 'MY_INFUSION', // research key (globally unique; used by Thaumonomicon/dedup)
+ 'minecraft:diamond', // result
+ 3, // instability
+ ['ignis 64', 'potentia 32'],// aspects: aspect tag + amount
+ 'minecraft:golden_apple', // central item
+ 'minecraft:blaze_rod', // components (one or more)
+ 'minecraft:ender_pearl'
+ );
+});
+```
+
+### 2. Crucible Recipe
+
+```js
+ServerEvents.recipes(event => {
+ event.recipes.thaumcraft.crucible(
+ 'MY_CRUCIBLE',
+ 'minecraft:diamond',
+ 'minecraft:coal', // catalyst
+ { aer: 16, ignis: 8 } // object syntax also works
+ );
+});
+```
+
+### 3. Shaped Arcane Recipe
+
+```js
+ServerEvents.recipes(event => {
+ event.recipes.thaumcraft.arcane(
+ 'MY_ARCANE',
+ 'minecraft:diamond_sword',
+ ['ignis 16', 'ordo 8'],
+ [' I ', ' I ', ' S '], // pattern: up to 3 rows
+ { I: 'minecraft:iron_ingot', S: 'minecraft:stick' }
+ );
+});
+```
+
+### 4. Shapeless Arcane Recipe
+
+```js
+ServerEvents.recipes(event => {
+ event.recipes.thaumcraft.arcane_shapeless(
+ 'MY_SHAPELESS',
+ 'minecraft:diamond_helmet',
+ ['aer 8', 'aqua 8'],
+ 'minecraft:leather_helmet',
+ 'minecraft:diamond',
+ 'minecraft:diamond',
+ 'minecraft:diamond'
+ );
+});
+```
+
+### 5. Item Aspect Tags
+
+```js
+ServerEvents.recipes(event => {
+ event.thaumcraft.addObjectTag('minecraft:stick', { aer: 1, ignis: 2 });
+});
+```
+
+### 6. Replacing an Existing Recipe (remove first, then register)
+
+```js
+ServerEvents.recipes(event => {
+ // NOTE: thaumcraft is a TOP-LEVEL binding, not event.thaumcraft
+ thaumcraft.removeInfusion('INFUSIONPROVIDER'); // remove TC infusion recipes by research key
+ // thaumcraft.removeArcane('RESEARCH_KEY'); // remove arcane recipes by research key
+
+ // then register the new recipe for a true replacement
+ event.recipes.thaumcraft.infusion(
+ 'INFUSIONPROVIDER',
+ 'thaumicenergistics:infusion_provider',
+ 0,
+ ['auram 10'],
+ 'ae2:interface',
+ 'minecraft:oak_planks'
+ );
+});
+```
+
+## Aspect Tag Reference (actual values in the TC4 port)
+
+**Important: the port uses the original Latin tags, not the TC4 English/localized names.**
+
+| Aspect | Tag | Aspect | Tag |
+|---|---|---|---|
+| Air | `aer` | Earth | `terra` |
+| Fire | `ignis` | Water | `aqua` |
+| Order | `ordo` | Entropy | `perditio` |
+| Void | `vacuos` | Light | `lux` |
+| Weather | `tempestas` | Motion | `motus` |
+| Crystal | `vitreus` | Life | `victus` |
+| Energy | `potentia` | Exchange | `permutatio` |
+| Death | `mortuus` | Darkness | `tenebrae` |
+| Soul | `spiritus` | Eldritch | `alienis` |
+| Magic | `praecantatio` | Aura | `auram` |
+| Slime | `limus` | Plant | `herba` |
+| Tree | `arbor` | Beast | `bestia` |
+| Flesh | `corpus` | Undead | `exanimis` |
+| Mind | `cognitio` | Senses | `sensus` |
+| Man | `humanus` | Tool | `instrumentum` |
+| Greed | `lucrum` | Craft | `fabrico` |
+| Cloth | `pannus` | Mechanism | `machina` |
+
+## Notes
+
+1. **Restart after editing scripts — `/reload` does NOT work**:
+ - TC recipes live in the `ThaumcraftApi.getCraftingRecipes()` in-memory list, which KubeJS
+ `/reload` never clears.
+ - This mod's dedup set (keyed by research) is also not cleared on `/reload`.
+ - If you need `/reload` support later, call `thaumcraft.clearRegistered()` in the script
+ to reset the dedup set.
+2. **Research keys are globally unique**: duplicate keys are skipped (prevents double
+ registration). Use a different key for same-name recipes.
+3. **Recipes do NOT go into the vanilla RecipeManager**: TC infusion/crucible/arcane recipes
+ live in TC's own in-memory list. The bridge calls the TC API directly during script
+ execution and emits a harmless placeholder recipe to KubeJS, so it does not affect other
+ mods' recipe operations. (The schema uses `typeOverride = minecraft:crafting_shapeless`
+ so KubeJS can find a serializer.)
+4. **Aspect lists accept two syntaxes**: array `['ignis 64']` or object `{ignis: 64}`.
+5. **Item arguments** accept: item id strings (`minecraft:xxx` / `ae2:xxx`), `Item.of(...)`,
+ and item tags (`#minecraft:planks`).
+6. **AE2's namespace is `ae2`**: e.g. the AE2 interface is `ae2:interface`,
+ NOT `appliedenergistics2:interface`.
+7. **KubeJS version compatibility**: the bridge is built on KubeJS 2101.7.x RecipeSchema API
+ (marked `@ApiStatus.Experimental`). Upgrading KubeJS to another patch version may require
+ adjustments to the bridge layer.
+
+## Troubleshooting
+
+| Symptom | Cause |
+|---|---|
+| Log shows `Serializer for type thaumcraft:infusion is not found` | Missing `typeOverride` (already fixed in code; rebuild) |
+| Log shows `Unknown aspect 'xxx'` | Wrong aspect tag — check the aspect table above |
+| Log shows `Item with ID xxx does not exist` | Wrong item id — remember the `ae2:` namespace |
+| No `TC-KubeJS-Bridge` lines in the log at all | Mod not installed, or script not in `server_scripts` |
+| Script reports `event.thaumcraft is undefined` | Use the top-level `thaumcraft.xxx`, not `event.thaumcraft.xxx` |
+
+## Project Layout
+
+```
+src/main/java/tckubejsbridge/
+├── TCKubeJSBridge.java // @Mod main class
+└── kubejs/
+ ├── TCKubeJSPlugin.java // KubeJS plugin: schemas + bindings
+ ├── TCRecipeKJS.java // Custom KubeRecipe base (bridge + placeholder serialization)
+ ├── TCRecipeBridge.java // TC API calls + dedup + removal + placeholder JSON
+ ├── AspectListComponent.java // Aspect list RecipeComponent
+ ├── InfusionRecipeKJS.java // Infusion recipe
+ ├── CrucibleRecipeKJS.java // Crucible recipe
+ ├── ShapedArcaneRecipeKJS.java // Shaped arcane recipe
+ └── ShapelessArcaneRecipeKJS.java // Shapeless arcane recipe
+src/main/resources/
+├── kubejs.plugins.txt // Plugin discovery file
+└── META-INF/neoforge.mods.toml
+example/infusion_example.js // Example script
+```
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..91d642c
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,60 @@
+plugins {
+ id 'net.neoforged.moddev' version '2.0.78'
+}
+
+repositories {
+ mavenCentral()
+ def localRepository = mavenLocal()
+ remove(localRepository)
+ add(0, localRepository)
+ // KubeJS / Rhino 官方 maven(备用,当前用本地 jar)
+ maven { url = 'https://maven.latvian.dev/releases' }
+}
+
+version = "${mod_version}+${neoforge_version}"
+group = mod_group
+
+base { archivesName = "KubeJS Thaumcraft" }
+
+java {
+ toolchain.languageVersion = JavaLanguageVersion.of(21)
+}
+
+neoForge {
+ version = neoforge_version
+ runs {
+ client { client() }
+ server { server() }
+ }
+ mods { "${mod_id}" { sourceSet sourceSets.main } }
+}
+
+dependencies {
+ // KubeJS 本地 jar(compileOnly:仅编译期引用,运行时由 KubeJS mod 提供)
+ compileOnly files('library/kubejs-neoforge-2101.7.2-build.368.jar')
+ // Rhino(KubeJS 依赖脚本引擎,2101.7.2 对应 2101.2.7-build.81)
+ compileOnly files('library/rhino-2101.2.7-build.81.jar')
+ // Thaumcraft 移植版(本地 jar)
+ compileOnly files('library/thaumcraft-0.2.2.34-port.1.jar')
+}
+
+tasks.withType(JavaCompile).configureEach {
+ it.options.encoding = 'UTF-8'
+ it.options.release = 21
+}
+
+def loaderRange = project.findProperty('loader_version_range') ?: '[4,)'
+def neoforgeRange = project.findProperty('neoforge_version_range') ?: '[21,)'
+def mcRange = project.findProperty('minecraft_version_range') ?: '[1.21.1,1.22)'
+def modVersion = mod_version
+
+processResources {
+ filesMatching('META-INF/neoforge.mods.toml') {
+ expand([
+ 'version': modVersion, 'loader_version_range': loaderRange,
+ 'neoforge_version_range': neoforgeRange, 'minecraft_version_range': mcRange
+ ])
+ }
+}
+
+jar { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }
diff --git a/example/infusion_example.js b/example/infusion_example.js
new file mode 100644
index 0000000..d35f840
--- /dev/null
+++ b/example/infusion_example.js
@@ -0,0 +1,93 @@
+// ============================================================
+// TC KubeJS Bridge — 示例脚本
+// 放置到 kubejs/server_scripts/ 目录后【重启游戏】生效(/reload 不生效)
+//
+// 依赖:thaumcraft(TC4 移植版)、kubejs、tckubejsbridge
+// ============================================================
+
+// ---------- 1. 注魔配方 ----------
+// event.recipes.thaumcraft.infusion(research, result, instability, aspects, central, ...components)
+ServerEvents.recipes(event => {
+ event.recipes.thaumcraft.infusion(
+ 'EXAMPLE_INFUSION', // research 键(用于魔导手册展示/去重)
+ 'minecraft:diamond', // 输出物品
+ 3, // 不稳定性
+ ['ignis 64', 'potentia 32'], // 所需源质(要素 tag + 数量,见 README 要素表)
+ 'minecraft:golden_apple', // 中心物品
+ 'minecraft:blaze_rod', // 祭品 1
+ 'minecraft:nether_star', // 祭品 2
+ 'minecraft:ender_pearl' // 祭品 3
+ );
+});
+
+// ---------- 2. 坩埚炼金配方 ----------
+// event.recipes.thaumcraft.crucible(research, result, catalyst, aspects)
+ServerEvents.recipes(event => {
+ event.recipes.thaumcraft.crucible(
+ 'EXAMPLE_CRUCIBLE',
+ 'minecraft:diamond',
+ 'minecraft:coal', // 催化剂(物品 id)
+ { aer: 16, ignis: 8 } // 对象写法也可
+ );
+});
+
+// ---------- 3. 奥术有序合成配方 ----------
+// event.recipes.thaumcraft.arcane(research, result, aspects, pattern, key)
+ServerEvents.recipes(event => {
+ event.recipes.thaumcraft.arcane(
+ 'EXAMPLE_ARCANE',
+ 'minecraft:diamond_sword',
+ ['ignis 16', 'ordo 8'],
+ [' I ', ' I ', ' S '], // pattern:3 行字符串
+ {
+ I: 'minecraft:iron_ingot',
+ S: 'minecraft:stick'
+ }
+ );
+});
+
+// ---------- 4. 奥术无序合成配方 ----------
+// event.recipes.thaumcraft.arcane_shapeless(research, result, aspects, ...ingredients)
+ServerEvents.recipes(event => {
+ event.recipes.thaumcraft.arcane_shapeless(
+ 'EXAMPLE_ARCANE_SHAPELESS',
+ 'minecraft:diamond_helmet',
+ ['aer 8', 'aqua 8'],
+ 'minecraft:leather_helmet',
+ 'minecraft:diamond',
+ 'minecraft:diamond',
+ 'minecraft:diamond'
+ );
+});
+
+// ---------- 5. 物品固有要素 ----------
+// event.thaumcraft.addObjectTag(item, aspects)
+ServerEvents.recipes(event => {
+ event.thaumcraft.addObjectTag(
+ 'minecraft:stick',
+ { aer: 1, ignis: 2 }
+ );
+});
+
+// ---------- 6. 替换已有配方(先删后注册) ----------
+// 例:简化"注魔供应器"配方 —— 中心 AE2 接口 + 10 灵气 + 1 橡木木板
+// 注意:thaumcraft 是顶层绑定,不要写 event.thaumcraft
+ServerEvents.recipes(event => {
+ thaumcraft.removeInfusion('INFUSIONPROVIDER'); // 删除原配方(8 祭品复杂版)
+
+ event.recipes.thaumcraft.infusion(
+ 'INFUSIONPROVIDER',
+ 'thaumicenergistics:infusion_provider',
+ 0,
+ ['auram 10'],
+ 'ae2:interface',
+ 'minecraft:oak_planks'
+ );
+});
+
+// ---------- 提示 ----------
+// - research 键全局唯一:重复键会被跳过(防止重复注册)
+// - 要素 tag 用 TC4 移植版的拉丁文原名(aer/ignis/aqua/terra/ordo/perditio/vacuos/lux/motus/potentia/auram 等),
+// 完整表见 README
+// - 物品 id 支持 AE2 命名空间 ae2:xxx(如 ae2:interface)
+// - 改脚本后必须重启游戏,/reload 不生效(TC 配方在内存列表)
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..58aac27
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,30 @@
+# Project
+mod_version=1.0.0
+mod_group=tckubejsbridge
+
+# Mod
+mod_name=KubeJS Thaumcraft
+mod_id=tckubejsbridge
+mod_author=AlkSur
+
+# Shared (must match root gradle.properties)
+minecraft_version=1.21.1
+neoforge_version=21.1.234
+
+# Dependency versions
+thaumcraft_version=0.2.2.34-port.1
+kubejs_version=2101.7.2
+rhino_version=2101.2.7-build.81
+org.gradle.jvmargs=-Xmx2G
+org.gradle.daemon=true
+org.gradle.parallel=true
+org.gradle.caching=true
+org.gradle.configuration-cache=true
+minecraft_version_range=[1.21.1,1.22)
+neoforge_version_range=[21,)
+loader_version_range=[4,)
+systemProp.http.proxyHost=127.0.0.1
+systemProp.http.proxyPort=7890
+systemProp.https.proxyHost=127.0.0.1
+systemProp.https.proxyPort=7890
+systemProp.http.nonProxyHosts=localhost|127.*
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..a4b76b9
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e18bc25
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..f3b75f3
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..9d21a21
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..0aacb99
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,9 @@
+pluginManagement {
+ repositories {
+ mavenLocal()
+ gradlePluginPortal()
+ maven { url = 'https://maven.neoforged.net/releases' }
+ }
+}
+
+rootProject.name = "tc-kubejs-bridge"
diff --git a/src/main/java/tckubejsbridge/TCKubeJSBridge.java b/src/main/java/tckubejsbridge/TCKubeJSBridge.java
new file mode 100644
index 0000000..9b42993
--- /dev/null
+++ b/src/main/java/tckubejsbridge/TCKubeJSBridge.java
@@ -0,0 +1,20 @@
+package tckubejsbridge;
+
+import net.neoforged.fml.common.Mod;
+
+/**
+ * TC KubeJS Bridge — 将神秘时代4(Thaumcraft 4 移植版)的配方系统桥接到 KubeJS。
+ *
+ * 提供 KubeJS 脚本可调用的:
+ * - event.recipes.thaumcraft.infusion(...) 注魔配方
+ * - event.recipes.thaumcraft.crucible(...) 坩埚炼金配方
+ * - event.recipes.thaumcraft.arcane(...) 奥术有序合成配方
+ * - event.recipes.thaumcraft.arcane_shapeless(...) 奥术无序合成配方
+ * - event.thaumcraft.addObjectTag(...) 物品固有要素
+ *
+ * 实际注册逻辑在 {@link tckubejsbridge.kubejs.TCKubeJSPlugin}(KubeJS 插件)。
+ */
+@Mod(TCKubeJSBridge.MODID)
+public final class TCKubeJSBridge {
+ public static final String MODID = "tckubejsbridge";
+}
diff --git a/src/main/java/tckubejsbridge/kubejs/AspectListComponent.java b/src/main/java/tckubejsbridge/kubejs/AspectListComponent.java
new file mode 100644
index 0000000..098e325
--- /dev/null
+++ b/src/main/java/tckubejsbridge/kubejs/AspectListComponent.java
@@ -0,0 +1,179 @@
+package tckubejsbridge.kubejs;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonPrimitive;
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.DataResult;
+import dev.latvian.mods.kubejs.recipe.KubeRecipe;
+import dev.latvian.mods.kubejs.recipe.RecipeKey;
+import dev.latvian.mods.kubejs.recipe.component.ComponentRole;
+import dev.latvian.mods.kubejs.recipe.component.RecipeComponent;
+import dev.latvian.mods.kubejs.recipe.component.RecipeComponentType;
+import dev.latvian.mods.kubejs.recipe.component.RecipeComponentValue;
+import dev.latvian.mods.kubejs.recipe.component.SimpleRecipeComponent;
+import dev.latvian.mods.kubejs.recipe.component.RecipeComponentWithCodec;
+import dev.latvian.mods.kubejs.script.ConsoleJS;
+import dev.latvian.mods.rhino.type.TypeInfo;
+import net.minecraft.resources.ResourceLocation;
+import thaumcraft.api.aspects.Aspect;
+import thaumcraft.api.aspects.AspectList;
+
+import java.util.Map;
+
+/**
+ * 将脚本中的源质列表转换为 Thaumcraft {@link AspectList}。
+ *
+ * 支持两种脚本写法:
+ * {@code
+ * ['ignis 64', 'potentia 32'] // 字符串数组:"要素名 数量"
+ * { ignis: 64, potentia: 32 } // 对象:{ 要素名: 数量 }
+ * }
+ */
+public final class AspectListComponent implements RecipeComponent {
+
+ public static final AspectListComponent INSTANCE = new AspectListComponent();
+
+ private static final RecipeComponentType> TYPE = RecipeComponentType.unit(
+ ResourceLocation.fromNamespaceAndPath("tckubejsbridge", "aspect_list"),
+ AspectListComponent.INSTANCE);
+
+ /** 要素名 → 数量 的对象/数组 编解码(仅作序列化展示,桥接时直接读值) */
+ private static final Codec CODEC = Codec.unboundedMap(Codec.STRING, Codec.INT)
+ .flatXmap(
+ map -> {
+ try {
+ return DataResult.success(fromMap(map));
+ } catch (Exception e) {
+ return DataResult.error(() -> "Invalid aspect list: " + e.getMessage());
+ }
+ },
+ list -> {
+ try {
+ return DataResult.success(toMap(list));
+ } catch (Exception e) {
+ return DataResult.error(() -> "Invalid aspect list: " + e.getMessage());
+ }
+ });
+
+ private AspectListComponent() {
+ }
+
+ @Override
+ public RecipeComponentType> type() {
+ return TYPE;
+ }
+
+ @Override
+ public Codec codec() {
+ return CODEC;
+ }
+
+ @Override
+ public TypeInfo typeInfo() {
+ return TypeInfo.of(Object.class);
+ }
+
+ @Override
+ public AspectList wrap(dev.latvian.mods.kubejs.recipe.RecipeScriptContext cx, Object from) {
+ if (from instanceof AspectList list) {
+ return list;
+ }
+ if (from instanceof String s) {
+ return parseString(s);
+ }
+ if (from instanceof java.util.List> list) {
+ AspectList result = new AspectList();
+ for (Object o : list) {
+ if (o instanceof String s) {
+ result.add(parseString(s));
+ } else if (o instanceof AspectList al) {
+ result.add(al);
+ }
+ }
+ return result;
+ }
+ if (from instanceof Map, ?> map) {
+ return fromMap(map);
+ }
+ // 兜底:尝试从 JSON 元素解析
+ if (from instanceof JsonElement json) {
+ if (json.isJsonObject()) {
+ return fromJsonObject(json.getAsJsonObject());
+ }
+ if (json.isJsonArray()) {
+ AspectList result = new AspectList();
+ for (JsonElement e : json.getAsJsonArray()) {
+ if (e.isJsonPrimitive() && e.getAsJsonPrimitive().isString()) {
+ result.add(parseString(e.getAsString()));
+ }
+ }
+ return result;
+ }
+ }
+ throw new IllegalArgumentException("Cannot convert " + from + " to AspectList");
+ }
+
+ private static AspectList parseString(String s) {
+ String[] parts = s.trim().split("\\s+");
+ Aspect aspect = Aspect.getAspect(parts[0]);
+ if (aspect == null) {
+ throw new IllegalArgumentException("Unknown aspect '" + parts[0] + "'");
+ }
+ int amount = parts.length > 1 ? Integer.parseInt(parts[1]) : 1;
+ return new AspectList().add(aspect, amount);
+ }
+
+ private static AspectList fromMap(Map, ?> map) {
+ AspectList result = new AspectList();
+ for (Map.Entry, ?> e : map.entrySet()) {
+ Aspect aspect = Aspect.getAspect(String.valueOf(e.getKey()));
+ if (aspect == null) {
+ throw new IllegalArgumentException("Unknown aspect '" + e.getKey() + "'");
+ }
+ result.add(aspect, ((Number) e.getValue()).intValue());
+ }
+ return result;
+ }
+
+ private static AspectList fromJsonObject(JsonObject obj) {
+ AspectList result = new AspectList();
+ for (Map.Entry e : obj.entrySet()) {
+ Aspect aspect = Aspect.getAspect(e.getKey());
+ if (aspect == null) {
+ throw new IllegalArgumentException("Unknown aspect '" + e.getKey() + "'");
+ }
+ result.add(aspect, e.getValue().getAsInt());
+ }
+ return result;
+ }
+
+ private static Map toMap(AspectList list) {
+ java.util.LinkedHashMap map = new java.util.LinkedHashMap<>();
+ for (Aspect aspect : list.getAspects()) {
+ if (aspect != null) {
+ map.put(aspect.getTag(), list.getAmount(aspect));
+ }
+ }
+ return map;
+ }
+
+ @Override
+ public void writeToJson(KubeRecipe recipe, RecipeComponentValue cv, JsonObject json) {
+ JsonObject obj = new JsonObject();
+ for (Aspect aspect : cv.value.getAspects()) {
+ if (aspect != null) {
+ obj.add(aspect.getTag(), new JsonPrimitive(cv.value.getAmount(aspect)));
+ }
+ }
+ json.add(cv.key.name, obj);
+ }
+
+ @Override
+ public void readFromJson(KubeRecipe recipe, RecipeComponentValue cv, JsonObject json) {
+ JsonElement v = json.get(cv.key.name);
+ if (v != null && v.isJsonObject()) {
+ cv.value = fromJsonObject(v.getAsJsonObject());
+ }
+ }
+}
diff --git a/src/main/java/tckubejsbridge/kubejs/CrucibleRecipeKJS.java b/src/main/java/tckubejsbridge/kubejs/CrucibleRecipeKJS.java
new file mode 100644
index 0000000..a3bd089
--- /dev/null
+++ b/src/main/java/tckubejsbridge/kubejs/CrucibleRecipeKJS.java
@@ -0,0 +1,23 @@
+package tckubejsbridge.kubejs;
+
+import net.minecraft.world.item.ItemStack;
+import thaumcraft.api.aspects.AspectList;
+
+/**
+ * 坩埚炼金配方 KubeRecipe:event.recipes.thaumcraft.crucible(research, result, catalyst, aspects)
+ */
+public class CrucibleRecipeKJS extends TCRecipeKJS {
+
+ public CrucibleRecipeKJS() {
+ }
+
+ @Override
+ protected void bridgeToTC() {
+ String research = getValue(TCKubeJSPlugin.RESEARCH);
+ ItemStack result = getValue(TCKubeJSPlugin.RESULT);
+ Object catalyst = getValue(TCKubeJSPlugin.CATALYST);
+ AspectList aspects = getValue(TCKubeJSPlugin.ASPECTS);
+
+ TCRecipeBridge.registerCrucible(research, result, catalyst, aspects);
+ }
+}
diff --git a/src/main/java/tckubejsbridge/kubejs/InfusionRecipeKJS.java b/src/main/java/tckubejsbridge/kubejs/InfusionRecipeKJS.java
new file mode 100644
index 0000000..006446c
--- /dev/null
+++ b/src/main/java/tckubejsbridge/kubejs/InfusionRecipeKJS.java
@@ -0,0 +1,28 @@
+package tckubejsbridge.kubejs;
+
+import net.minecraft.world.item.ItemStack;
+import thaumcraft.api.aspects.AspectList;
+
+import java.util.List;
+
+/**
+ * 注魔配方 KubeRecipe:event.recipes.thaumcraft.infusion(research, result, instability, aspects, central, ...components)
+ */
+public class InfusionRecipeKJS extends TCRecipeKJS {
+
+ public InfusionRecipeKJS() {
+ }
+
+ @Override
+ protected void bridgeToTC() {
+ String research = getValue(TCKubeJSPlugin.RESEARCH);
+ ItemStack result = getValue(TCKubeJSPlugin.RESULT);
+ int instability = getValue(TCKubeJSPlugin.INSTABILITY);
+ AspectList aspects = getValue(TCKubeJSPlugin.ASPECTS);
+ ItemStack central = getValue(TCKubeJSPlugin.CENTRAL);
+ List components = getValue(TCKubeJSPlugin.COMPONENTS);
+
+ TCRecipeBridge.registerInfusion(research, result, instability, aspects, central,
+ components == null ? new ItemStack[0] : components.toArray(new ItemStack[0]));
+ }
+}
diff --git a/src/main/java/tckubejsbridge/kubejs/ShapedArcaneRecipeKJS.java b/src/main/java/tckubejsbridge/kubejs/ShapedArcaneRecipeKJS.java
new file mode 100644
index 0000000..3f248a0
--- /dev/null
+++ b/src/main/java/tckubejsbridge/kubejs/ShapedArcaneRecipeKJS.java
@@ -0,0 +1,44 @@
+package tckubejsbridge.kubejs;
+
+import net.minecraft.world.item.ItemStack;
+import thaumcraft.api.aspects.AspectList;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 奥术有序合成配方 KubeRecipe:
+ * event.recipes.thaumcraft.arcane(research, result, aspects, pattern, key)
+ *
+ * 其中 pattern 为行字符串数组(如 ['ABA','BCB','ABA']),
+ * key 为字符→物品映射(如 {A:'minecraft:iron_ingot', B:Item.of('minecraft:gold_ingot')})。
+ */
+public class ShapedArcaneRecipeKJS extends TCRecipeKJS {
+
+ public ShapedArcaneRecipeKJS() {
+ }
+
+ @Override
+ protected void bridgeToTC() {
+ String research = getValue(TCKubeJSPlugin.RESEARCH);
+ ItemStack result = getValue(TCKubeJSPlugin.RESULT);
+ AspectList aspects = getValue(TCKubeJSPlugin.ASPECTS);
+ List pattern = getValue(TCKubeJSPlugin.PATTERN);
+ dev.latvian.mods.kubejs.util.TinyMap key = getValue(TCKubeJSPlugin.KEY);
+
+ // 组装 TC addArcaneCraftingRecipe 参数:pattern 行 + 字符/物品映射
+ List