跳到主要内容

Java 公开 API

这页列的是别的模组可以直接调用的入口。数据包能表达的东西(定义、条件、行为、消耗)不需要 Java;只有当你要在代码里问"这个坐标的灵气是多少""替他执行这个技能""这件物品算不算这条定义"时,才用得上这里的东西。

三件事先说清楚:

  • 这些入口留在各自的模块包里(registry、util、runtime/*、data),只有要实现的接口在 com.iafenvoy.mxt.api,见接口。
  • 服务端权威:扣费、修炼、技能、伤害、阵法结算都只在服务端做,客户端只渲染与发请求。哪些入口在客户端会失效,集中在服务端与客户端的边界。
  • 想知道某条线为什么长这样(而不是"有哪些方法"),看技术页:灵气计算、伤害系统、敌我识别系统。

先按"要做什么"找入口​

你要做的事入口在哪
读一张数据包注册表里的定义MxtDatapackRegistriesregistry
把定义翻成显示名DefinitionTextutil
拼一行提示框数值TooltipTextutil
求一个数据包数值NumberProviderutil.formula
判断"这件物品算不算这条定义"ItemMatcherutil.matcher
问一堆物品是哪一档、让自己的定义参与定档QualityService / QualityProviderruntime.item / api
问某个坐标有多少灵气AuraServiceruntime.world
读写实体身上的一条数值ResourceServiceruntime.resource
加修炼进度、突破、设置境界CultivationServiceruntime.cultivation
读写一个生物的寿元、让它转世LifespanApiReincarnation api
执行一个技能、受理一次按键AbilityServiceruntime.ability
自己造成一次伤害DamageCalculationServiceruntime.damage
给阵法加一种功能模块FormationActionTypedata.formation
问"这一下会不会被阵法拦下"FormationProtectionruntime.formation
问"它算不算我的人"FriendServiceruntime.friend
算一件物品的货币价值CurrencyValueServiceruntime.economy
预览一炉、开一炉、终止一炉AlchemyWorkstationServiceruntime.alchemy
添加一种运行期长按用途ItemUseCycle / ItemUseCyclesapi / runtime.hold

数据定义怎么读​

MxtDatapackRegistries​

包 com.iafenvoy.mxt.registry。40 张原生数据包注册表的声明与统一读取入口,/reload 重建与客户端同步都交给原版注册表系统,这个类不持有任何快照。

九张物品/方块表(item_aura、currency、default_quality、item_binding、weapon_binding、tool_binding、blueprint_binding、block_aura、heat_source)也在这里:它们就是普通的数据包注册表,物品键的按 items 认领条目(currency 用 item)、方块键的按 blocks 认领条目,读法与下面完全一样,没有第二套入口。要按物品堆查"哪条定义适用于它"用 ItemMatcher.find(...)。字段与文件位置见注册表总览。

按 id / holder 取值(读服务端注册表):

方法作用备注
get(ResourceKey<? extends Registry<T>> key, Identifier id)按 id 取定义值条目不存在给 Optional.empty()
holder(key, Identifier id)按 id 取 holder读的是服务端注册表
holders(key)遍历整表的全部 holder

显式传入注册表访问器(客户端用这几个):

方法作用备注
holder(HolderLookup.Provider access, key, Identifier id)客户端按 id 取 holder 的唯一正确入口不碰 ServerLifecycleHooks
holders(Provider access, key) / holders(RegistryAccess access, key)遍历整表
get(Provider access, key, Identifier id)取值同上

这一层不过滤任何东西:条目在不在注册表里就是全部答案,而"停用一条定义"是加载期的事(neoforge:conditions,见停用一条定义),被条件挡掉的条目根本不在表里。所以读了 holder 就能直接动用,没有"读到了一条被关掉的定义"这种状态。

标签查询:

方法作用备注
isTagged(key, Identifier id, Identifier tagId) / isTagged(key, Holder<T> holder, Identifier tagId)在不在某个标签里条目不存在返回 false

带容错与规模:

方法作用备注
holderOrEmpty(key, Identifier id)与 holder(key, id) 同义,但没有服务端时不抛,给空给同时在客户端跑的读者用(例如 CurseService.definitionState)
registry(key)拿到原版 Registry<T> 本体没有运行中的服务端时抛 IllegalStateException
size(key)该表的条目数与 registry(...) 同一条服务端断言
registries()本类登记过的全部注册表 key

要点:

  • 类里不缓存注册表实例。要缓存就按注册表实例开键(/reload 不换实例,世界加载才换),参考 DamageElements。
  • 附件里存着、而当前包已经不提供的 Holder(条目被 neoforge:conditions 挡掉,或直接删了文件)在两次世界加载之间不会自己消失:附件只在世界加载时解码,那一刻找不到定义的引用会被容错列表丢掉、重新进一次世界就干净了;而 /reload 不重解附件,所以旧引用在本次会话里还在。要问"现在还在不在"就按 id 回查注册表(Elements.of / activeSpiritRoots / SecretRealmService.enter 都是这么做的),别拿手里这枚 holder 当它还存在的证据。
  • newDatapackRegistries(NewRegistry) 由启动事件调用,不是给业务代码用的;表与 codec 的登记在 MxtResourceKeys。

DefinitionText​

包 com.iafenvoy.mxt.util。把定义翻成显示名,键是 <category>.<注册表命名空间>.<定义命名空间>.<路径>,其中 category 就是注册表 id 的 path。

方法作用备注
name(Holder<?> holder) / name(ResourceKey<?> key)取显示名本体内部推荐入口:类别由注册表自己答(key.registry() 的 path),调用方不传类别串
name(Identifier id, String category)手里只有一个裸 id 时硬假定 mxt 命名空间,非 mxt 的定义别用这个重载
description(Holder<?> holder) / description(ResourceKey<?> key)取描述键是名字键加 .description;描述只有这一个出口
minorStage(Holder<?> holder, int index) / minorStage(ResourceKey<?> key, int index)取子境界名键是本境界名字键加 .minor_stage.<下标>,下标从 0 起
derived(Holder<?> holder, String suffix) / derived(ResourceKey<?> key, String suffix)名字键再接一个后缀description 与 minorStage 都走它;别的"属于某个定义的文本"也走它
category(Identifier registry)取某个注册表的 category就是它的 path
key(ResourceKey<?>) / key(Identifier registry, Identifier id)由条目 / 注册表+定义生成文本键
key(String category, String registryNamespace, Identifier id)底层拼接拼定义键的唯一出口
resolved(Component text)这段文本是"真文本"还是"没被翻译的裸键"读客户端语言文件,属显示层判断

要点:别在别处再拼一套名字键;定义里一个文本字段都没有,名字与描述永远按注册 id 现算,包唯一能给定义配词的办法就是翻译生成的键。键没被翻译时渲染出来就是键本身(不是空串),resolved(...) 就是用来判这一点的——印描述之前一律先过它。

TooltipText​

包 com.iafenvoy.mxt.util。提示框里数值与一行的拼法,纯计算、无状态。

方法作用备注
number(double value)最多两位小数、去掉多余的 0用 Locale.ROOT,不受语言环境影响:3.0 → 3、3.50 → 3.5、3.456 → 3.46
signed(double value)带符号的数值非负一律加 +(0 → +0)
join(List<? extends Component> parts) / join(Component... parts)把若干片段拼成一行片段之间插的是翻译键 TooltipText.SEPARATOR(tooltip.mxt.separator),不是字面标点

要点:列表标点各语言不同,别在别处硬编码「、」或「, 」——凡是 join 过的行都必须经过这里。

NumberProvider​

包 com.iafenvoy.mxt.util.formula。一个"数值"的抽象:JSON 里的常数、exp4j 表达式字符串,或 saturn:number_provider_type 里的一种结构化 provider。

成员作用备注
double evaluate(FormulaContext context)求值实现必须自己用 assertFinite 兜住非有限结果
MapCodec<? extends NumberProvider> codec()该实现的 MapCodec新实现要同时在 saturn:number_provider_type 里登记,否则带类型的 codec 认不出
default boolean assertFinite(double value)校验运行期结果是否有限非有限时报一条含 using 0 的诊断并返回 false;它本身不改数值,调用方负责给兜底值
NumberProvider.CODEC数据包统一入口按 double → 表达式字符串 → {type:...} 的顺序尝试解码;编码侧固定写回 {type:...}
NumberProvider.TYPED_CODEC按 "type" 分派
NumberProvider.FINITE_DOUBLE_CODEC拒绝非有限 double加载期校验用

内建类型(saturn:number_provider_type):constant(默认)、expression、context_variable、sum、uniform、binomial、weighted_list、conditional,以及交给脚本的 js。

要点:一个字段只写一种形状即可(数字 / 表达式字符串 / {type:...}),别为某个字段另造数值格式——Cost 之外的一切数值都走它。

ItemMatcher​

包 com.iafenvoy.mxt.util.matcher。"哪条定义适用于这件物品"的匹配器:entries() 里任意一项命中即命中(或语义,不是且)。

成员作用备注
List<Entry> entries()匹配项列表实现必须给全
int priority()排序权重没有默认实现:每个实现返回自己在 JSON 里声明的 priority;没有那个字段的匹配器返回 DEFAULT_PRIORITY
DEFAULT_PRIORITY定义没写 priority 时的值(0)三个物品条件、消耗 mxt:item 与框架内置的两个长按声明没有这个字段,也用它;档位不按 Entry 类型分
ORDER唯一的排序比较器(priority 降序)需要 Holder、不能用 findAll 的调用方(HoldLookup、ItemAuraService)按它自己排,别各写一份
find(Registry<T> registry, ItemStack stack) / find(Stream<T> matchers, ItemStack stack)第一个命中无命中给 Optional.empty()
findAll(...)(注册表版与流版)全部命中按 priority 降序排序
ENTRIES_CODECentry 列表的 Codec同时认单个对象与数组两种写法

ItemMatcher.Entry 的成员:matches(ItemStack)、default boolean itemLevel()、codec(),以及两个 codec 常量 SHORTCUT_CODEC(简写:裸物品 id → item、物品标签 → tag)与 CODEC(先试简写,再试带 type 的对象)。Entry 不带优先级:谁赢由定义自己的 priority 决定。

Entry 种类(saturn:item_matcher_entry_type,默认 item):item、tag、wildcard、regex;运行期的模块另外注册了 spirit_storage、herb_tag、technique。

要点:

  • find 的"第一个"是 priority 数值最大的那个定义,不是注册顺序,也不是"匹配得最具体"的那个,与 aura_zone、element_reaction 同一个方向;priority 相同的才取决于传入流的顺序。
  • priority 是认领表自己的字段(artifact、item/weapon/pill/tool/blueprint/technique 六种 binding、spirit_herb、item_aura、currency 与 default_quality,默认 0,加载期不校验范围),所以"通用定义 + 特地点名定义"共存时由数据包写死谁先;点名的条目不会因此更靠前。ArtifactHold 直接回读它那件法器的字段;消耗 mxt:item、三个物品条件与两个框架内置的长按声明(功法阅读、灌注)没有这个字段,恒为 DEFAULT_PRIORITY。
  • itemLevel() 是缓存安全的分界线:它返回 true 表示"命中与否只由物品本身决定",按物品开缓存的调用方只能缓存这类项;会读堆上的组件 / NBT 的项,以及答案来自另一条定义的项(mxt:herb_tag 问的是哪条 spirit_herb 认领这件物品)必须每个堆都问一次。
  • 简写只覆盖 item 与 tag;其它实现走简写编码会抛 IllegalArgumentException。

物品目录与创造栏​

物品选择器是 Picker 模组的功能(本模组通过 jarJar 内置它):界面、/picker 命令、开屏包与目录读取都在那边,本模组只在启动时登记自己的分类(data/PickerCategories)。所以这里没有"本模组的目录 API"——要读这些行,用 Picker 的 PickerContents。

分类就是一张注册表:/picker <分类 id> 的分类 id 就是注册表 id,PickerCatalogue.categories() 按登记顺序给出全部分类,PickerCatalogue.category(Identifier) 按 id 找一个。minecraft:item 与 minecraft:block 是 Picker 自己的内置分类,本模组不再登记它们;本模组登记的分类与行形状见物品选择器。

行怎么来由那张注册表自己的 provider 决定——物品形状的表(item_aura、currency、default_quality、各种 binding…)一行是它认领到的那件物品(currency 看 item,其余看 items),方块形状的表(block_aura、heat_source)一行是它 blocks 认领到的那个方块,行名用条目自己的名字(定义写了 name 就读它,否则按 id 推翻译键)。每个类目里的行序就是那张注册表自己的顺序。

匹配条目里只有物品与标签能直接点名物品,其余条目会拿每一件已注册物品试一遍(通配符、正则、整份材料、mxt:spirit_storage 这类都能这样出行);只有读堆数据的那几种(mxt:quality 的档位、mxt:technique 组件、按药性筛的灵草)对裸物品答否,因此不出行。

给创造栏用 PickerContents.stacksOf / stacksOfMod:它们按原版创造栏自己的规则(ItemStackLinkedSet,同物品同组件算同一件)去掉重复,而且每一份都是拷贝。去重不是装饰:mxt:aura 的每一行都是同一颗灵石当替身,把行原样塞进创造栏,原版的 accept 会当场抛"这个堆已经在栏里了"。行视图(itemsOf*)一个定义一行,不去重;原版创造栏的 accept 还要求 count == 1,本体的行都是 1。每条查询都要求自己传注册表访问器(HolderLookup.Provider,BuildCreativeModeTabContentsEvent.getParameters().holders() 就是它),所以客户端用客户端已同步的表、服务端用服务端的表,不存在"读到另一侧"的问题。

// 自己的创造栏:让这个模组贡献过的定义(以及它自己的物品)全进来
@Override
public void buildContents(BuildCreativeModeTabContentsEvent event) {
if (event.getTabKey() != MY_TAB) return;
for (ItemStack stack : PickerContents.stacksOfMod(event.getParameters().holders(), "mymod"))
event.accept(stack);
}

物品品质​

QualityService 与 QualityProvider​

包 com.iafenvoy.mxt.runtime.item(服务类)与 com.iafenvoy.mxt.api(接口)。"这一堆物品是哪一档"只有一个解析顺序,只在 QualityService.find 一处,按三层往下取:堆上的 mxt:quality 组件 → 这一堆携带的定义自己声明的档 → 注册表 mxt:default_quality。本模组今天有九个定义走第 2 层。字段级写法见品质。

api/QualityProvider 是给定义类型用的契约:一个定义类型实现它,就是在说"我的物品起步的那一档归我自己报"。

public interface QualityProvider {
Optional<Holder<ItemQuality>> defaultQuality();
}

实现它本身什么都不改——一类定义还要用 QualityService.carry 登记一个载体组件,才算进了第 2 层:

重载用在
carry(Supplier<? extends DataComponentType<Holder<T>>> type)组件的值就是那份定义,直接读 Holder<T>
carry(Supplier<? extends DataComponentType<C>> type, Function<C, Optional<Holder<T>>> extract)组件是个 record,定义包在里面,由 extract 取出来

T extends QualityProvider:给一个没实现这个接口的定义类型登记载体编译不过。

要点:

  • 注册顺序只有一条:本模组自己的载体在 QualityService 类加载时的静态块里登记完,附属模组的载体排在后面。一次查询按登记顺序走完整个列表,先给出答案的那一个赢。
  • 这是一份登记,不是"扫一遍堆上碰巧带着的组件":两个 provider 同时挂在一堆上时,"谁先被问到"只能由登记顺序说了算——按组件遍历的先后没有任何契约。
  • 载体必须只凭这一堆就答得出:两个重载都不给它注册表,因为"这件物品能不能用"那道闸门(QualityService.check)手里只有品质注册表。想按 id 回查别的注册表的写法在这里走不通。
  • 引用失效就当作没有:载体取出的 holder 没绑定时(当前包把那条定义删了)这一层答空,解析继续往下走,而不是给出一个死档位。
  • 定义类型只是"被问到"的入口:真正读的是定义自己的 defaultQuality(),所以一个类型实现了接口、又登记了载体之后,它的每一条定义都自动参与第 2 层。

QualityService 对外的其余入口:

方法作用备注
find(Provider access, ItemStack stack)这一堆解析出的档三层顺序的唯一实现;空堆给空
set(stack, quality) / clear(stack)写 / 摘掉覆盖组件clear 之后这一堆先退回它携带的定义那一层,再看注册表 mxt:default_quality
hasOverride(stack)堆上有没有那个组件与"解析出档没有"是两个问题:没写组件也可能从定义或注册表 mxt:default_quality 读到档
canUse(user, stack) / check(user, stack)"能不能用"的闸门check 给出 Failure 枚举;绑定条件、档位自己的 condition、丹药次数与冷却都在这里
modifier(...)取品质的三个修正之一是乘数;缺失、非有限或 ≤ 0 一律按 DEFAULT_MODIFIER(1.0)
displayName(quality)品质这一档自己的名字,套上它自己的颜色列一个档位时的统一写法(就是 coloredName(quality, DefinitionText.name(quality)));/quality get|set|upgrade、灵根 / 体质列表与画符结算都走它
coloredName(quality, text)给一段别的文本套上这一档的 color品质没写颜色就原样返回

存单堆覆盖用 set,别自己往组件里写;被拒绝的使用想给玩家同一句提示,就调 notifyCannotUse(它是公开的,好让排在闸门自己的优先级之下、但仍要看这次拒绝的交互也报得出来)。

灵气与资源​

这两件事一起讲,因为最容易混:灵气(aura)是身份,资源(resource)是数值。字段怎么写见 aura 与 resource,"某坐标的灵气到底怎么算出来"见灵气计算。

AuraService​

包 com.iafenvoy.mxt.runtime.world。"某坐标的灵气是多少"的唯一解析器:biome → dimension → 自定义区域 → 阵法覆写四层依次求解,再把环境池与该区块的可变存量合成最终答案。

查询(双端都能调):

方法作用备注
getPositionAura(Level level, BlockPos pos)坐标最终灵气:环境池 + 该区块存量 + 周围方块的距离衰减贡献 + 阵法的 max_bonus 上限加成/mxt aura query 就是这个口径;内部会取 / 初始化该区块的灵气附件
getSensedAura(Level level, BlockPos pos)环境浓度:只算环境,刻意不含区块存量资源条的 mxt:environment_concentration 是这个口径;查不到区域定义时给空池,其余字段照带

写区块存量:

方法作用备注
consume(Level level, BlockPos pos, Map<Holder<Aura>, Double> costs)从该区块存量扣一笔灵气,返回扣成功没有全有或全无:任一项非有限、为负、或存量不足都返回 false,并且一点不扣
change(Level level, BlockPos pos, Map<Holder<Aura>, Double> amounts)直接增减该区块存量(可为负)返回 void,失败没有任何信号(任一值非有限就整笔静默放弃);缺失的灵气永远不会被隐式创建
initialize(AuraChunkAttachment chunk, Resolved resolved, BlockPos pos)把一层环境池写进区块存量并打上 template 标记服务端写路径

结果类型:AuraResult(一张 Holder<Aura> → AuraPool(amount, maximum, regenPerTick, supplied) 的表,外加规则、来源与 SourceKind)与 Resolved(holder, definition, kind, maxBonus)(一层解析结果,id() 在没有 holder 时给 mxt:empty)。

要点:

  • "坐标最终灵气"与"环境浓度"是两个入口:前者含区块存量,后者不含。两者的池子都按 Holder<Aura> 分开,而 aura 与 resource 一对一,所以没有"给我某个 resource 的池子"这种入口。
  • 真正能花的账面是区块存量,只有 consume / change 能改;getPositionAura 给的是合成视图(里面还混着方块贡献)。
  • 一次查询是"本刻快照":结果按 (坐标, 游戏刻) 记忆、整表在每个 tick 末尾丢弃(AuraQueryCache),所以 consume / change 之后在同一刻再查,读到的仍是改动前的值(新激活的阵法同理,下一 tick 才看得到)——要立刻拿到新值,就用这次调用的返回值自己记账。详见灵气计算。
  • supplied 单独记"这份量里有多少来自脚下的方块发射器",因为允许花掉脚下地形的消费者(比如抽取环境的阵法)必须先扣掉自己这一份,否则重复计账。
  • 优先级是后者胜:biome < dimension < 自定义区域 < 阵法覆写。dimension 绑定是"替换"biome,不是被 biome 压过——直接读那句英文注释的顺序容易读反。
  • 阵法覆写只在有区域 holder 且是服务端时发 AuraZoneEvent.Override,被取消就保留低优先层;没有 holder 的覆写静默穿透。
  • 客户端两个查询都能调,但 dimension 标签的匹配可能和服务端不同(dimension stem 是不同步给客户端的注册表),别把客户端结果当权威。

ResourceService​

包 com.iafenvoy.mxt.runtime.resource。把 resource 定义的边界(min / max)一致地施加到初始化、变更、被动恢复三条路径上。

方法作用备注
initialize(ResourceHolderAttachment holder, Holder<Resource> resource, FormulaContext context)按 default_value 落户并钳到边界已有值就不动(changed = false);边界解不出或默认值非有限 → invalid
change(ResourceHolderAttachment holder, Holder<Resource> resource, double amount, FormulaContext context)增减这条数值越界是钳制而不是拒绝;内部先 initialize(首次访问自动落户);结果与当前值相同就返回 unchanged
regenerate(ResourceHolderAttachment holder, Holder<Resource> resource, NumberProvider regen, long elapsedTicks, FormulaContext context)被动恢复:regen × elapsedTicks 之后走 changeelapsedTicks < 0 抛 IllegalArgumentException——这个类里唯一抛异常的地方
formulaContext(CultivationAttachment spirit, Holder<Resource> resource, FormulaContext base)给公式挂上这条数值的上下文只读友好:修炼附件由调用方自己传(读出来用 getExistingData)
formulaContext(LivingEntity entity, Holder<Resource> resource, FormulaContext base)从实体取修炼状态再挂会创建附件(内部走 getData(MxtAttachments.CULTIVATION)),只读场景别用这个重载
resolveBounds(Resource definition, FormulaContext context)求 min / max任一非有限或 min > max → Optional.empty();要自己做只读检查就用它,别另写一套钳制

结果类型:Result(valid, changed, value)——失败时 value 是 NaN,必须先看 valid() 再看数;以及 Bounds(min, max)。

要点:

  • 三个写入方法只改传进来的那个附件,自己不取、也不创建附件;唯一会隐式创建附件的是收 LivingEntity 的 formulaContext(...)。
  • 按 id 的重载(initialize(holder, id, context)、change(holder, id, amount, context) 与一对 formulaContext(..., id, base))每次都自己按 id 去注册表取定义,所以当前包里没有的条目(文件被删、或被 neoforge:conditions 挡掉)会被跳过;id 解析不到时静默失败:写入给 invalid,两个 formulaContext 原样返回 base。
  • 这个类和 AuraService 都没有自己的服务端检查,是否只在服务端调用靠调用点自律。

修炼与境界​

CultivationService​

包 com.iafenvoy.mxt.runtime.cultivation。纯静态工具类,修炼这条线的算术都在这。

突破(唯一入口):

方法作用备注
attempt(LivingEntity entity, CultivationAttachment spirit, ResourceHolderAttachment resources, Holder<Aura> aura, FormulaContext context, BooleanSupplier conditionsMet)尝试突破到下一境界这是本类里唯一做服务端限定的方法:客户端直接得到 Failure.SERVER_ONLY,不写日志
attempt(..., Identifier auraId, ...)同上,灵气用 id 给出解析不出灵气时给 NO_NEXT_REALM

进度与小境界:

方法作用备注
addProgress(LivingEntity, Holder<Aura>, double amount, FormulaContext)加修为进度返回实际接受量;非法 amount 或没有下一境界给 0.0
addProgressForChain(...)同上,可传实体或 CultivationAttachment两个重载
remainingProgressForChain(CultivationAttachment, Holder<Aura>, FormulaContext)还差多少到下一境界
minorStage(Holder<Aura>, CultivationAttachment, FormulaContext) / minorStage(RealmStage, double progress, FormulaContext)当前小境界(从 0 起)分段算术只有这一处,内部有重入守卫;没有 minor_stages 或数值非法给 NaN

只读与管理:

方法作用备注
breakthroughStatusForChain(LivingEntity, Holder<Aura>, FormulaContext)突破状态(到了没、条件满足没、是不是自动、进度区间)自动突破 tick 与信息面板共用同一个答案
pendingConditionsForChain(LivingEntity, Holder<Aura>)还差哪些条件
setRealm(CultivationAttachment spirit, Identifier target)直接设置境界(管理员用)返回是否真的改了;它没有实体参数,因此没有服务端检查

结果类型:BreakthroughResult(advanced, failure, failedResource, costs)、BreakthroughStatus(reached, conditionsMet, automatic, minimumExperience, maximumExperience)、enum Failure {DISABLED, WRONG_AURA, NO_NEXT_REALM, INSUFFICIENT_PROGRESS, CONDITIONS, INSUFFICIENT_RESOURCE, INVALID_FORMULA, CANCELLED, SERVER_ONLY}。

要点:

  • 边界不一致:只有 attempt 挡客户端;addProgress* / setRealm / minorStage 都不检查,在客户端调会真的写附件。
  • 内容自己声明的条件在扣费之前评估,所以条件不满足不会白花资源。
  • INVALID_FORMULA 同时兼任"一切非资源不足的扣费失败"的归一化出口;failedResource 只在扣费阶段资源不足时非空。
  • Failure.DISABLED 只有脚本桥会产出(MxtKubeJsApi.tryBreakthrough 在灵气 id 解析不到时),attempt 自己不产出它。
  • Failure.MAX_PROGRESS 已于 2026-09-25 删除(它是一条走不到的分支):进度上限不可能成为拒绝突破的理由——threshold() 要求某一段的 max_experience ≥ breakthrough_exp,所以进度被顶在 max_experience 时必然已经满足 progress ≥ breakthrough_exp,此时只会因条件、代价或取消而失败。内联的 Failure 分支若写了它,编译期就会报错。

CultivationRanks​

包 com.iafenvoy.mxt.runtime.cultivation。回答"这个身体在这条 aura 链上排第几",是链序号的唯一出口。

方法作用备注
realmRank(CultivationAttachment spirit, Holder<Aura> aura)该实体在这条 aura 链上的境界序号,凡人算作 0、首境界是 1,往上递增当前阶段不在链上给 -1;链上没有首境界(或那条首境界没被链走到)也给 -1,不是 0,否则破链的头会排在凡人之上

要点:

  • 序号把凡人算进来,所以凡人不是"首境界"的别名:0 与 1 是两个不同的数,公式能分辨"还没进任何一个大境界"和"在第一重的开头"。
  • 排名只来自那份共享索引(DatapackCache)走出来的链:索引里没有这条链就给 -1——链本身没走通(断链、分叉、成环)与索引还没建立(世界尚未加载,或刚退出世界)都是这一种。
  • 凡人那一支不查索引:没进过任何大境界是身体自己的事实,与链有没有被索引无关,所以只要这个灵气声明了 first_realm 就是 0(没声明 first_realm 则无处可去,给 -1)。
  • 链内比较(DatapackCache.isRealmAtLeast)用的是未经这层偏移的原始下标,两侧同时比较,所以加不加这个 1 结论相同。

LifespanApi​

可选的 Reincarnation 模组提供 com.iafenvoy.reincarnation.api.LifespanApi,供附属模组直接集成时使用。调用应隔离在兼容类中,仅当 ModList.get().isLoaded("reincarnation") 为真时加载。MiXianTu 本体不调用此 API,突破增减寿元完全由境界阶段的 success_action 配置。

方法作用
get(Entity)只读账本,不创建附件
set(LivingEntity, long) / add(LivingEntity, long)改写或增减有限寿元
unlimited(LivingEntity)关闭账本
requestRebirth(LivingEntity, RebirthMode, Component)排队转世,成功仅表示接受
rebirth(LivingEntity, RebirthMode, Component)立即执行,返回结果实体

写入限定逻辑服务端主线程,返回 Saturn Result。NEW_LIFE 会替换玩家,应使用返回的新实体继续操作;RESET 清理原实体的附件并重开寿元。保留标签、资源策略和取消事件均由 Reincarnation 管理。见寿元和 ReincarnationEvents。

AbilityService​

包 com.iafenvoy.mxt.runtime.ability。纯静态工具类。技能执行只有一条路,所有入口都在这里。

方法作用备注
use(Holder<Ability> ability, Entity actor, AbilityAttachment attachment, ResourceHolderAttachment resources, long gameTime, FormulaContext context)施放一个已授予的技能cast_time > 0 时只登记截止时间,结果是 casting = true
useCarried(..., @Nullable Vec3 origin)物品自带的技能不要求已授予(物品即许可);非瞬发技能给 CARRIED_NOT_INSTANT
finishCast(Holder<Ability>, Entity, AbilityAttachment, ResourceHolderAttachment, long gameTime, FormulaContext)引导到点后落地没到点就原样返回引导中的结果
gate(ToggleContext context)共用闸门:授予 / 冷却 / 条件 / 消耗不执行效果;由 AbilityActivationService.activate 在 Togglable#gated 为真时调用
tickChannel(Holder<Ability>, Entity, AbilityAttachment, ResourceHolderAttachment, long gameTime, FormulaContext)引导每 tick 结算只有服务端实体 tick 桥会调;任何失败都停止引导
stopChannel(AbilityAttachment)停止引导
cancelCast(Holder<Ability>, AbilityAttachment, long gameTime)中断蓄力不退费
executeTargetAction(Ability definition, Entity actor, Entity target, FormulaContext context)执行技能的目标行为给事件桥复用,origin 为空

返回类型:UseResult(committed, casting, failure, failedResource, amounts)(三态:已提交 / 引导中 / 失败)、GateResult(approved, failure, failedResource)、PrepareResult(approved() 即 use != null)、PreparedUse、CommitResult、ChannelResult(state, failure, nextTick, amounts)、enum State {INACTIVE, WAITING, PULSED, STOPPED}、enum Failure {DISABLED, NOT_GRANTED, COOLDOWN, INSUFFICIENT_RESOURCE, INSUFFICIENT_COST, INVALID_FORMULA, CONDITION_FAILED, NO_CHARGES, CANCELLED, PERMISSION_DENIED, SERVER_ONLY, CARRIED_NOT_INSTANT, NO_TARGET, NOT_APPLICABLE}(14 个;元素亲和按 PERMISSION_DENIED 拒绝)。

要点:

  • 凡是"按下某个开关",服务端一律先经 runtime/ability/AbilityActivationService——轮盘、命令、KubeJS 与符箓都走它,别在别处再写一套"按下某个开关"的分派。实现了 Togglable 且 gated(ctx) 为真时它才回头调这里的 gate。
  • 冷却与消耗全由这条路负责:cooldown 字段自己会写 mxt:cooldown 状态,内容不需要再声明一遍冷却。
  • 技能生效时做什么由类型回答:四个动作字段(entity_action / target_selector / target_condition / bi_entity_action)由会跑动作的类型各自声明并各自执行,所以这条路走到"该做什么"那一步就是 AbilityEffect.run(definition.type(), actor, context, origin)——一个静态入口,内部只判断类型有没有实现 AbilityEffect 这个能力接口,不是按具体类型分派;四个字段的共享执行在 ActionCarrier 上。四个字段各自的时机见ability(技能)· 四个动作字段。
  • 定向施放(mxt:targeted)走的也是这条路:它实现能力接口 AbilityApplier(reach 挑这次够得着的实体 + payload 每个目标上跑的那条技能),运行时在付款之前先问一次 reach——一个人都没选中=NO_TARGET,载荷类型没有单目标那一半=NOT_APPLICABLE(两个都进上面的 Failure);它自己的价格、冷却与元素亲和照常由这条路收一次。见定向施放。
  • 世界动作永不回滚,所以复合技能先 prepare 校验、再统一 commit。
  • 这个类自己不检查客户端,也从不产生 Failure.SERVER_ONLY——客户端那道 SERVER_ONLY 是调用方(例如 KubeJS 桥 MxtKubeJsApi)自己返回的。直接调 use 在客户端会真的动手。
  • 所有结果里的 amounts / costs 只用于展示与记录;别拿它当回滚依据。

伤害​

DamageCalculationService​

包 com.iafenvoy.mxt.runtime.damage。这一击的唯一结算管线:成形在攻击方(outgoing),减免在受击方(incoming)。两层为什么必须分开、数据包里怎么写元素,见伤害系统。

唯一施放入口:

方法作用备注
deal(@Nullable Entity attacker, Entity target, double amount, Optional<Holder<DamageType>> damageType, @Nullable FormulaContext context)本模组自己造成伤害时唯一该调的方法:自己建 DamageSource、判 mxt:no_bonus 直通、跑第一层、再交给原版 hurtServer客户端(目标不在 ServerLevel)与非有限 / 非正的金额都返回 0.0D;damageType 参数不可为 null,没有类型就传 Optional.empty();返回的是成形值、未经原版减免(护甲、无敌帧),它不是"实际扣了多少血"

第一层:攻击方成形(纯计算,不落伤害):

方法作用
outgoing(@Nullable Entity attacker, Entity target, double amount, @Nullable FormulaContext context, Set<Holder<Element>> elements)基础值 × damage_multiplier × element_modifier × 自冲突 ×(攻击方 damage_dealt_multiplier × 这一击对目标灵根的 overcomes)
outgoing(...)(不传 elements 的简写)没声明伤害类型时,这一击的元素取攻击者自己的灵根元素
selfConflictMultiplier(@Nullable Entity attacker)主手物品元素与在效灵根 conflicting_elements 相冲时的倍率;每个元素只乘一次(多少条灵根冲突都不叠乘)
physiqueMultiplier(@Nullable Entity entity, boolean dealt)在效体质的 damage_dealt_multiplier(dealt=true)/ damage_taken_multiplier(dealt=false),多条相乘;一律用持有者自己的公式上下文
masteryMultiplier(@Nullable FormulaContext context) / elementMultiplier(@Nullable FormulaContext context)读 damage_multiplier / element_modifier;缺省与非法值按 1.0,写 0 是被尊重的(不是"没写")。注意技能施放不再写 element_modifier:亲和倍率并进了 damage_multiplier,这个入口只给别的调用方显式设值时用

第二层:受击方减免(纯计算)与工具:

方法作用备注
incoming(LivingEntity target, Set<Holder<Element>> attacking, double amount)× 受击方 adapted_to × 受击方 damage_taken_multiplier主重载;只有 DamageEventBridge 在 LivingIncomingDamageEvent 里调它一次,别自己再调一遍(那等于二次减免)
incoming(LivingEntity target, DamageSource source, double amount) / incoming(LivingEntity target, @Nullable Entity attacker, double amount)从伤害来源或攻击者反推元素再减免上面那个主重载的两个简写
adaptationMultiplier(LivingEntity target, Set<Holder<Element>> attacking)受击方的 adapted_to被克制是攻击方的便宜,受击侧不再加一次
overcomeMultiplier(Set<Holder<Element>> attacking, Entity target)攻击方元素对目标灵根的 overcomes每一对匹配都相乘(两个元素都克制就都算)
attachmentMultiplier(LivingEntity target)受击者携带的法器的 attachment_multiplier 连乘只缩放这一击留下的元素附着量,不影响元素反应的效果
bypasses(DamageSource source)这个伤害类型在不在 mxt:no_bonus 里直通=不乘任何因子、不留元素,但原版自己的减免照旧——它是"不归本模组加成口径管",不是免疫
source(Level level, @Nullable Entity attacker, Optional<Holder<DamageType>> damageType)造这一击的 DamageSource归因在这里定(玩家 → playerAttack、生物 → mobAttack、都没有 → generic);调用方自己建 source 会丢击杀归属

公开常量:DAMAGE_MULTIPLIER 与 ELEMENT_MODIFIER(公式上下文里的两个变量名;施放只写前者)、NO_BONUS(TagKey<DamageType>)。

DamageElements​

包 com.iafenvoy.mxt.runtime.damage。回答"这一击是什么元素",并给两层提供同一次读取。

方法作用备注
strike(Level level, Optional<Holder<DamageType>> type, @Nullable Entity attacker) / strike(DamageSource source)这一击的元素集合管线与伤害条件共用的唯一规则:伤害类型被元素认领就是那些元素,没人认领就回落到攻击者灵根
reading(Level level, ...) / reading(DamageSource source)元素 + 来源 + 每个元素会留多少附着量三个字段来自同一次注册表查询;减免与元素附着必须共用这一次读取,否则两个数不同步
of(RegistryAccess access, Holder<DamageType> type) / of(DamageSource source)谁认领了这个伤害类型(不含回落)没人认领给空集;建索引时读的就是各元素定义自己的 damage_types
typeOf(RegistryAccess access, Holder<Element> element)取该元素 damage_types 里第一条能解析出来的类型标签形式会展开成第一个匹配元素
resolveType(RegistryAccess access, List<Either<Holder<Element>, TagKey<Element>>> elements, Optional<Holder<DamageType>> damageType)决定一次声明式打击该以什么类型出行空结果 = 保持它原本的读取(攻击者灵根);声明失败只警告,绝不让加载失败
checkDeclaration(RegistryAccess access, ...)校验声明的元素确实认领了这个类型首次使用时检查,不在加载期(注册表是并行解码的,加载期检查会让同一个包时过时不过)

类型:Strike(elements, attachment, origin)、Claim(element, attachment),以及 enum Origin {TYPE, ROOTS}——attaches() 说明这一击会不会在目标身上留下附着,元素反应只由 TYPE 启动。

要点:

  • 一击的元素必须能从 DamageSource 读出来:减免层手里只有它。
  • 缓存按伤害类型 Registry 实例开键(元素表与伤害类型表在同一步重载,一个键同时察觉两者);/reload 不换实例,所以缓存不会失效也不该失效,上限 4 张注册表。
  • 同一个伤害类型被多个元素认领时每条认领都相乘,建索引时会打一条警告。

阵法​

FormationActionType 与 MxtFormationActionTypes​

阵法框架(结构、半径、消耗)在 Formation 上,"这个阵法干什么"由它的 actions 列表决定,而列表里每一项就是一个功能模块。

  • FormationActionType(com.iafenvoy.mxt.data.formation)是模块的形状:一个 codec(),加一个按 JSON 的 "type" 字段分派的 CODEC(必须是 Codec 而不是 MapCodec,因为阵法持有的是模块列表),外加四个可选的节拍方法 activate / tick / interval / inactivate——激活、每个周期与拆除这三个时刻现在也是模块。
  • 分派表 mxt:formation_action_type 是固有注册表(默认项 empty),经 NewRegistryEvent 在代码里静态注册,不在 MxtDatapackRegistries 那批数据包注册表里。
  • 于是:数据包能自由新增 mxt:formation 条目(模块组合与参数),但新增不了模块类型。多加一种模块 = 一条记录 + 一次 DeferredRegister 注册,模块自己实现那几个节拍,data 包因此可以直接调运行时服务。
  • 现在合法 type 有 10 个,都登记在 MxtFormationActionTypes:mxt:empty(EMPTY,也是分派表默认项)、mxt:attack(ATTACK)、mxt:buff(BUFF)、mxt:aura(AURA)、mxt:protection(PROTECTION)、mxt:particle(PARTICLE)、mxt:send_message(SEND_MESSAGE)、mxt:activate(ACTIVATE)、mxt:tick(TICK)、mxt:inactivate(INACTIVATE)。
  • 注册只经 MxtFormationActionTypes.REGISTRY 一次,别在别处再注册一遍,也别另建第二张阵法模块表。

FormationProtection​

包 com.iafenvoy.mxt.runtime.formation。"阵法里的护持(ward)禁不禁止这个动作"的唯一判定点:破坏、放置、使用、交互、攻击、爆炸、怪物破坏都问同一个问题、拿同一个答案。

方法作用备注
prevented(ServerLevel level, Action action, @Nullable BlockPos target, @Nullable UUID actorId)这个动作在这个位置 / 这个行为者上是否被拦下true = 禁止。收的是 UUID 而不是玩家或实体:服务端审计在没有在线玩家时也要能跑规则;actorId == null 是合法的(爆炸、怪物破坏)
covers(ProtectionFormationAction ward, Action action)这个护持的哪个开关管这个动作与数据包字段一对一(block_break → BREAK,等等)
hasProtection(Formation definition)这份定义里有没有护持模块纯定义查询,不看世界
delegationHandsOver()"委托给领地插件"此刻是否真的让位服务端配置「兼容 → 委派需领地保护」关着,或者领地插件确实在保护
claimsOnlyRefuses(ServerLevel level, BlockPos controller)claims_only 模式下这里是否因"没领地"而拒绝没装领地插件时是失效(打一条警告)而不是报错——不能因操作员无法满足的原因让阵法放不下去
foreignClaimRefuses(ServerLevel level, BlockPos controller, @Nullable UUID actorId)护持要立到别人已认领的地上时是否拒绝需要服务端配置「兼容 → 立阵需许可」开着且装了领地插件,否则直接放行;权限本身去问领地插件,不复刻它的规则(第二份实现会漂移)
warnIfDelegationFallsBack(Identifier id, Formation definition)激活时提醒"声明说委托给领地插件,其实没有领地保护,自家标志位仍然生效"每个定义只提醒一次

FormationProtection.Action 是调用方与判定点之间的唯一词汇表:BREAK、PLACE、INTERACT、EXPLOSION、MOB_GRIEFING、ENTITY_INTERACT、ATTACK_ENTITY、ITEM_USE。新增一种动作=这里加值 + covers 加分支 + 数据包标志位,三处必须同改。

要点:

  • 判定同时看动作两端:行为者位置或目标位置在半径内,这个护持就适用;目标为空时仍可凭行为者位置拒绝。
  • 豁免顺序:行为者为空不豁免;护持没有主人时谁都不豁免;行为者在归属名单上 → 豁免(名单上的每一位都算阵主);只有护持声明了 spare_friends 且服务端配置「阵法 → 敌我识别」开着,才会去问 FriendService.identify(...) == TRUE(逐位阵主都问一遍);任何好友源都认不出的实体一律被拦。
  • 让位成立时这个护持完全不管(跳过,不是降级),触发它的有两条独立路径:模块自己声明 delegate_to_claims,或者 claim_linkage == claims_precedence 且控制点区块已被认领。
  • 领地只按控制点所在的那个区块问,这样横跨边界的护持不会被两套规则同时管。
  • 别在事件订阅者里再写一套护持判定。

敌我识别​

FriendService​

包 com.iafenvoy.mxt.runtime.friend。"这个是不是我的人"的唯一提问入口:友伤、护持豁免、AI 判断都走它。机制与名单命令见敌我识别系统。

方法作用备注
isFriend(Entity judge, Entity candidate)布尔化的答案只有 TRUE 算"是";DEFAULT 与 FALSE 都算否
identify(Entity judge, Entity candidate)三态判定转下面那个
identify(UUID judgeId, @Nullable Entity judge, Entity candidate)三态判定,裁判可以离线先发 FriendEvent.Relation,没人表态才回落内置好友名单
builtin(Entity judge, Entity candidate) / builtin(UUID judgeId, @Nullable Entity judge, Entity candidate)直接问内置好友名单,不发事件可以安全地在 Relation 监听器里调——这正是"名单 + 我自己追加的"能表达出来的原因

要点:

  • 不记忆化:每次调用都会发一次事件,而监听器有权查世界。同一个 tick 里反复问的调用方要自己存答案。
  • 内置判定的顺序是:自己永远是自己的人 → 裁判没加载就查 FriendCache(离线镜像)→ 裁判不是玩家就是 FALSE(只有玩家能有名单)→ 否则读好友附件(用 getExistingData,只读,不创建空附件)。
  • 这个类自己没有端判定,事件照发;客户端别指望它给权威答案。

FriendEvent​

包 com.iafenvoy.mxt.event。好友系统的识别钩子,发布在 NeoForge.EVENT_BUS 上(不是模组总线),而且实际被发布的是嵌套的 FriendEvent.Relation——订阅要点名它。

成员作用备注
Relation(UUID judgeId, @Nullable Entity judge, Entity candidate) / Relation(Entity judge, Entity candidate)构造公开构造器,外部可以自己发一条
getJudgeId()裁判 UUID永远有值——数据存在服务端管理器里的好友源(队伍、阵营)因此在玩家离线时也能作答
getJudge()裁判实体离线时为空,这是设计要求而不是错误
getCandidate()被判定者
setResult(TriState)写下本监听器的判断TRUE = 当成我的人;FALSE = 不是,不管名单怎么说(压过包括玩家自己名单在内的一切来源);DEFAULT = 交回名单
getResult() / isAnswered()读当前结论 / 是否已经有人表态FriendService 只在 isAnswered() 为真时采信事件

要点:

  • 它不是可取消事件:要否决就 setResult(FALSE)。
  • result 是单个可变字段:后表态的覆盖先表态的,没有聚合、也没有"只问一次"的守卫。想表达"在名单基础上再追加",就在监听器里自己调 FriendService.builtin(...) 再合并。
  • 别在监听器里再 post 一次 Relation(会绕回 identify)。

货币​

CurrencyValueService​

包 com.iafenvoy.mxt.runtime.economy。物品本位货币定义的服务端读取 API:只算价值、给兑换报价,不扣费——货币不是消耗。格式见 currency。

方法作用备注
unitValue(Item item) / unitValue(ItemStack stack)单件单价需要当前服务端;取不到给 OptionalLong.empty(),不抛异常也不写日志
unitValue(Provider access, ...)(含带持有者与 FormulaContext 的重载)显式注册表 / 带持有者版空堆给 of(0L);只有带 FormulaContext 的那个重载把上下文一路传进 unavailable_when 判定与品质倍率
value(ItemStack stack) / value(@Nullable Entity holder, ItemStack stack)整堆价值 = 单价 × 数量单价查不到、或相乘越过 Long.MAX_VALUE 一律给 empty,绝不截断夹取
totalValue(Collection<ItemStack> stacks)一堆堆的总价值任一堆不是货币、或累加溢出 → empty
exchangeOffers(ItemStack input)(以及注册表版与带持有者版)这个输入能换到哪些报价只取**这件物品认领到的(priority 最大的)**定义的 exchanges 并摊平;赢家不可用时为空,没有服务端也给空列表
isExchangeInput(RegistryAccess registryAccess, ItemStack input)这堆能不能放进兑换输入格(还不看数量够不够)要求认领这件物品的赢家可用且 exchanges 非空
definition(Provider access, ItemStack stack)只按 matcher 找货币定义不判可用性——别拿 isPresent() 当"能用"
unavailableReason(Provider access, @Nullable Entity holder, ItemStack stack)它为什么不可用holder == null 时恒为 empty("没有持有者"就问不出原因);取 unavailable_when 里第一条条件为真的条目

ExchangeOffer(output, cost) 是一条报价:output() 返回的是拷贝,可以直接改而不污染别的报价。

CurrencyValue.unavailableWhen 是一列 DescribedEntry<ItemCondition>(形状与绑定表的 conditions 同一个)不是枚举——"有哪些取值"由数据包的 unavailable_when 列表决定,那段 description 是给玩家看的可翻译文本而不是错误码。

要点:

  • empty 与 0L 是两种结论:empty = 不是货币 / 没有服务端 / 溢出 / 找不到定义;0L = 是货币,但空堆或当前不可用;兑换那边"没有报价"表现为空列表。
  • 不带持有者的查询会把任何写了 unavailable_when 的定义当成不可用(内部要求那份列表为空);要按条件判定就传 holder。
  • 品质倍率是"物品货币价值遇见品质"的唯一地方:乘的是被定价那一堆自己解出的品质的 value_multiplier;乘积小于 1、越过 long 上界或不再有限时保留声明面额,而不是夹取。

炼丹​

AlchemyWorkstationService​

包 com.iafenvoy.mxt.runtime.alchemy。丹炉的唯一受理点:预览、开炉、终止与每刻推进都在这里,方块实体只回答状态(见 AlchemyWorkstation)。

方法作用备注
start(ServerPlayer player, AlchemyWorkstation station)开一炉返回 StartResult(started, failure, qualityFailure)。整条判定链跑完才扣料:结构、炉型、品质、投料摆放与容量、温度够不够得到、环境灵气、产物仓装不装得下;被 alchemyCraft 的 Pre 取消、或监听器改动了真实库存,都给 CANCELLED 且不还原库存。
preview(ServerPlayer player, AlchemyWorkstation station)预览同一套判定,不接收所选配方返回 AlchemyPreview(mixture, candidates, resolvedRecipe, blocker, parameters, qualityFailure, successOutputs, failureOutputs);开炉走的就是同一份 parameters,所以预览与实跑不会给出两个答案。
abort(ServerLevel level, BlockPos pos, AlchemyWorkstation station) / abort(..., AlchemyFailure reason)终止活动批次按失败结算一次,已投入的材料不返还;已经 READY 或已经结算过的批次不再结算第二次。
furnaceDefinition(Provider access, ItemStack stack)由一件炉体解析炉型规格堆是空的、没有组件、组件里的 id 为空、或那条定义不在注册表里,都给空。
tick(ServerLevel level, BlockPos pos, AlchemyWorkstation station)每刻推进一次由核心方块实体自己的服务端 tick 调;推炉温、进出阶段、生成待产出与结算都在这里。

结果类型:StartResult、AlchemyPreview、Parameters(开炉时求值一次、冻结进批次的配方向量),以及 enum AlchemyFailure {FURNACE_QUALITY, QUALITY_CONDITIONS, BINDING_CONDITIONS, UNBOUND, MAX_USES, COOLDOWN, NO_FURNACE, STRUCTURE, ACTIVE, CAPACITY, SLOTS, ZERO_POWER, NOT_HERB, INSUFFICIENT, CONFLICT, IMBALANCE, AMBIGUOUS, TEMPERATURE, ENVIRONMENT, INVALID_FORMULA, OUTPUT_CAPACITY, CANCELLED, DISABLED}(23 个)。

要点:

  • 温度与产物不是外部写进去的:Parameters 在开炉那一刻求值一次并冻结,活动批次读的是这份冻结值,/reload 之后新批次才读新配方。
  • start 与 preview 共用同一条判定:温度与产物容量只在"接受还是拒绝已经选出的那一条"这一步参与,不会让判定改选一条更弱的配方。
  • 供热方块回答两个数,见 AlchemyHeatSource。

运行期物品使用​

com.iafenvoy.mxt.api.ItemUseCycle 描述一次长按或消费用途。在 NeoForge 游戏事件总线上监听 ResolveItemUseCycleEvent,检查 getHolder() / getStack() 后调用 claim(cycle);第一个认领者生效。内置 Hold 使用 HIGH,丹药兜底使用 NORMAL。

方法约定
id()用途 Identifier;客户端与服务端一致
durationTicks() / animation()固定的正整数 tick 时长与使用动画
canStart(holder, stack)开始资格;默认允许
tick(holder, stack, remainingTicks)两侧调用;业务写入自行限制在服务端
finish(level, holder, stack)服务端完成一次,返回最终手部栈;默认保留原栈
isConsumption()默认 false;只有消费用途完成才触发丹药结算

实现应为不可变描述,不持有实体或实时物品栈。需要消费时由 finish 实现扣除,并让 isConsumption() 返回 true;创造模式不减数量仍可计作消费。普通阅读用途不结算物品上的丹药效果。

会话由 runtime.hold.ItemUseCycles 管理,不写入物品组件。右键自动解析;代码主动开始时,可先调用 prepare(holder, stack),成功后再走物品的 use 或实体的 startUsingItem。栈须在该实体手中,Shift 点击和自带原生使用行为的物品不被接管。

基础 Item 的开始、时长、动画和完成方法支持会话。自定义物品若覆盖这些方法,需委托对应基础实现才能参与。正常结束、取消、空闲、实体移除和断线均由服务清理;原版剩余物品与冷却处理保留。客户端远端姿态由同步后的使用标志重建,不增加网络消息。

基础方法先执行自己的行为。只有 use 返回 PASS、没有开始使用且手部栈未改变时,才尝试会话;时长仅在原版返回 0 时补入,动画仅在返回 NONE 时补入。完成方法若已替换物品栈,或改变其数量、组件,就保留该结果并关闭会话,不再调用用途的 finish。用途完成回调仍在原版剩余物品和冷却处理之前执行。

服务端与客户端的边界​

这一节把散在各处的边界收在一处——上面每个入口只讲它自己的例外。

  • 只有服务端会结算:扣费、修炼进度、突破、技能、伤害、阵法、货币价值都是服务端事实。客户端调用顶多得到"什么都没发生"的返回值。
  • 但"客户端安全"不是服务类的默认属性:本体内部的 KubeJS 桥(MxtKubeJsApi)在每个入口自己写 isClientSide() 检查并返回 SERVER_ONLY / false / 0,且不写日志;而 AbilityService 与 CultivationService(除 attempt)本身没有这道检查。写给别的模组调用的新入口时,照 KubeJS 桥的做法在边界上挡一次。
  • 不要在渲染线程查服务端注册表:MxtDatapackRegistries 里凡是不带 Provider / RegistryAccess 的重载都读当前服务端,要么改用带访问器的重载,要么把结果缓存下来。
  • 失败用结果记录,不用异常:所有 *Result 记录(UseResult、GateResult、BreakthroughResult …)都带一个 Failure 枚举与可选的 failedResource,正常拒绝不会抛异常。
  • 客户端只发意图:新增交互时只传 id 与选择,服务端自己重新解析定义再判定,见网络协议与服务端权威。