本篇教程由作者设定使用 CC BY-NC-SA 协议。

看标题就知道我要分析代码来介绍裂变堆的爆炸逻辑了,不过也不排除会有点别的东西,说不好呢。

代码来自于 github 上开源的仓库,所用的代码逻辑从 1.16.x 到 1.19.x 未发生更改,但我不保证之后的版本同样如此。

代码逻辑主要来自于 mekanism.generators.common.content.fission.FissionReactorMultiblockData.java,之后简称为此代码。为什么 mcmod 没有行内代码块?

文章灵感来自于这篇教程,感谢大佬的研究。

为了方便阅读,具有意义的结论以淡蓝色标注。


0x00 常量定义

INVERSE_INSULATION_COEFFICIENT : double = 10000
INVERSE_CONDUCTION_COEFFICIENT : double = 10

waterConductivity              : double = 0.5

COOLANT_PER_VOLUME             : int    = 100000
HEATED_COOLANT_PER_VOLUME      : long   = 1000000
FUEL_PER_ASSEMBLY              : long   = 8000

MIN_DAMAGE_TEMPERATURE         : double = 1200
MAX_DAMAGE_TEMPERATURE         : double = 1800
MAX_DAMAGE                     : double = 100

EXPLOSION_CHANCE               : double = 1 / 512000

你应该还尚不知道里面的大部分东西有什么用,没关系,我们之后会用到。


0x01 爆炸time when?

我们首先需要关心的是爆炸由什么代码调起,这样才能分析实际代码逻辑。

EXPLOSION_CHANCE 是一个很有趣的常量,在此代码中搜索发现只有两处使用,另一处为函数 void createMeltdown(Level world):

private void createMeltdown(Level world) {
    RadiationManager.INSTANCE.createMeltdown(world, getMinPos(), getMaxPos(), heatCapacitor.getHeat(), EXPLOSION_CHANCE,
        MekanismGeneratorsConfig.generators.fissionMeltdownRadius.get(), inventoryID);
}

基本可以认为这就是我们的目标,此函数由另一个函数 handleDamage 调起,这会是我们之后的核心,而 handleDamage 由函数 tick 调起,望文生义为每 tick 执行。

显然,handleDamage 是控制爆炸的关键代码,同时,我们将爆炸换用更好的表述「熔毁」(即 meltdown,区别于 explosion)。


0x02 handleDamage

先贴代码:

private void handleDamage(Level world) {
    double lastDamage = reactorDamage;
    double temp = heatCapacitor.getTemperature();
    if (temp > MIN_DAMAGE_TEMPERATURE) {
        double damageRate = Math.min(temp, MAX_DAMAGE_TEMPERATURE) / (MIN_DAMAGE_TEMPERATURE * 10);
        reactorDamage += damageRate;
    } else {
        double repairRate = (MIN_DAMAGE_TEMPERATURE - temp) / (MIN_DAMAGE_TEMPERATURE * 100);
        reactorDamage = Math.max(0, reactorDamage - repairRate);
    }
    // consider a meltdown only if we're passed the damage threshold and the temperature is still dangerous
    if (reactorDamage >= MAX_DAMAGE && temp >= MIN_DAMAGE_TEMPERATURE) {
        if (isForceDisabled() && MekanismGeneratorsConfig.generators.fissionMeltdownsEnabled.get()) {
            //If we have meltdowns enabled, and we would have had one before, but they were disabled, just meltdown immediately
            // if we still meet the requirements for a meltdown
            setForceDisable(false);
            createMeltdown(world);
        } else if (world.random.nextDouble() < (reactorDamage / MAX_DAMAGE) * MekanismGeneratorsConfig.generators.fissionMeltdownChance.get()) {
            // Otherwise, if our chance is hit either create a meltdown if it is enabled in the config, or force disable the reactor
            if (MekanismGeneratorsConfig.generators.fissionMeltdownsEnabled.get()) {
                createMeltdown(world);
            } else {
                setForceDisable(true);
            }
        }
    } else if (reactorDamage < MAX_DAMAGE && temp < MIN_DAMAGE_TEMPERATURE) {
        //If we are at a safe temperature and damage level, allow enabling the reactor again
        setForceDisable(false);
    }
    if (reactorDamage != lastDamage) {
        markDirty();
    }
}

我们拆解来看。

double lastDamage = reactorDamage;
double temp = heatCapacitor.getTemperature();
if (temp > MIN_DAMAGE_TEMPERATURE) {
    double damageRate = Math.min(temp, MAX_DAMAGE_TEMPERATURE) / (MIN_DAMAGE_TEMPERATURE * 10);
    reactorDamage += damageRate;
} else {
    double repairRate = (MIN_DAMAGE_TEMPERATURE - temp) / (MIN_DAMAGE_TEMPERATURE * 100);
    reactorDamage = Math.max(0, reactorDamage - repairRate);
}

lastDamage 的功能是异于这一小块代码的,但是不好拆分,我们之后再说。

temp 是反应堆目前的热量,如果温度大于一个温度阈值,那么会按照某种算法计算一个损伤值加到已有损伤值上,否则计算一个修复值减到已有损伤值上(但不会低于 0)。

既然计算式中有一堆常量,我们就把常量代入算一下。

温度阈值:1200

损伤值:min(temp, 1800) / 12000

修复值:(1200 - temp) / 120000

也就是说,最少会有 0.1 per tick 的损伤值,之后每上升 1K 温度会导致 1/12000 per tick 的损伤值,直到达到 0.15 per tick。

而修复值随温度降低而升高,每 1K 温度提高 1/120000 per tick 的修复值,直到最终降低到 0.0075 per tick(即 300K 时,这是理论常温)。

注意,这里面的数值都是累加到损伤值显示的百分号前面的,比如本来是 20%,温度是 1800K,1 tick 后损伤值会显示为 20.15%。

if (reactorDamage >= MAX_DAMAGE && temp >= MIN_DAMAGE_TEMPERATURE) {
    if (isForceDisabled() && MekanismGeneratorsConfig.generators.fissionMeltdownsEnabled.get()) {
        //If we have meltdowns enabled, and we would have had one before, but they were disabled, just meltdown immediately
        // if we still meet the requirements for a meltdown
        setForceDisable(false);
        createMeltdown(world);
    } else if (world.random.nextDouble() < (reactorDamage / MAX_DAMAGE) * MekanismGeneratorsConfig.generators.fissionMeltdownChance.get()) {
        // Otherwise, if our chance is hit either create a meltdown if it is enabled in the config, or force disable the reactor
        if (MekanismGeneratorsConfig.generators.fissionMeltdownsEnabled.get()) {
            createMeltdown(world);
        } else {
            setForceDisable(true);
        }
    }
}

其中 isForceDisabled 与 setForceDisable 是对于 boolean 变量 forceDisable 的 getter/setter 函数。

@ContainerSync
private boolean forceDisable;

void setForceDisable(boolean forceDisable) {
    if (this.forceDisable != forceDisable) {
        this.forceDisable = forceDisable;
        markDirty();
        if (this.forceDisable) {
            //If we are force disabling it, deactivate the reactor
            setActive(false);
        }
    }
}

@ComputerMethod
public boolean isForceDisabled() {
    return forceDisable;
}

MekanismGeneratorsConfig.generators.fissionMeltdownsEnabled.get() 返回是否在配置文件中启用熔毁,默认为 true。

MekanismGeneratorsConfig.generators.fissionMeltdownChance.get() 是配置文件中反应堆熔毁的概率,这个值默认是 0.001。

首先是一个判断,如果损伤值超过最大损伤值,且温度超过损伤阈值,进行一系列逻辑,这个条件称为熔毁条件。

熔毁条件:温度 ≥ 1200K 并且 损伤值 ≥ 100

先看代码块里面第二个分支的逻辑。从 0 到 1 之间随机一个小数,如果随机数小于已有损伤值除最大损伤值再乘熔毁概率,则再进入一个判定:若熔毁启用,则发生熔毁,否则将 forceDisable 记为 true(同时会导致反应堆强制关机)。代入常量计算一下。

熔毁概率 = (损伤值 / 100) * 配置熔毁概率 => 损伤值 * 0.00001,每 tick 检测一次。

例如,当损伤值为 1000% 时,熔毁概率为 1%,此时反应堆有 81.8% 的概率在一秒内不熔毁。这个时候建议把反应堆拆了重建。

我们再来看第一个分支,若 forceDisable 为 true 且启用熔毁则发生熔毁。这实际上发生于你在反应堆满足熔毁条件且熔毁判定成功后,把配置从不允许熔毁改成了允许熔毁。

else if (reactorDamage < MAX_DAMAGE && temp < MIN_DAMAGE_TEMPERATURE) {
    //If we are at a safe temperature and damage level, allow enabling the reactor again
    setForceDisable(false);
}

如果反应堆不满足熔毁条件了,就把 forceDisable 改成 false。这说明如果反应堆满足了熔毁的条件,然后回到安全的损伤值,然后再在配置里开启熔毁,然后再触发熔毁条件时,并不会立刻熔毁。当然如果你运气差正好碰上熔毁概率那就当我没说

if (reactorDamage != lastDamage) {
    markDirty();
}

更新损伤值数据。

我们可以得出一些结论。

无论反应堆是否在运行,只要满足熔毁条件,并且在配置文件中开启了熔毁,就有可能发生熔毁。

无论反应堆是否在运行,只要温度超过 1200K,反应堆就一定会积攒损伤值。

损伤值的恢复受到温度的影响且十分缓慢,所以一次性将损伤值降到 0 比开一会关一会要高效,并且最好不要产生损伤值。

改配置逃避熔毁是有效的,但是在脱离熔毁条件之前不要重新打开熔毁开关。


0x03 熔毁时在做什么?有没有空?可以来拯救吗?

只知道爆炸发生的条件并不能满足我们,我们还想知道 createMeltdown 这个函数究竟是如何工作的。

private void createMeltdown(Level world) {
    RadiationManager.INSTANCE.createMeltdown(world, getMinPos(), getMaxPos(), heatCapacitor.getHeat(), EXPLOSION_CHANCE,
          MekanismGeneratorsConfig.generators.fissionMeltdownRadius.get(), inventoryID);
}

参数里面分别是多方块结构(也就是反应堆)的边界,温度,某个常量,配置的熔毁范围,以及一个无关紧要的变量。

继续追踪调用的函数。

public void createMeltdown(Level world, BlockPos minPos, BlockPos maxPos, double magnitude, double chance, float radius, UUID multiblockID) {
    meltdowns.computeIfAbsent(world.dimension().location(), id -> new ArrayList<>()).add(new Meltdown(minPos, maxPos, magnitude, chance, radius, multiblockID));
    markDirty();
}

其中,meltdowns 是一个 Map<ResourceLocation, List<Meltdown>>。

这段代码就是将一个保存有熔毁信息的 Meltdown 存入了位置对应的列表中,我们再看看哪里会有对 meltdowns 的实际处理。

public void tickServerWorld(Level world) {
    ...

    // update meltdowns
    List<Meltdown> dimensionMeltdowns = meltdowns.getOrDefault(world.dimension().location(), Collections.emptyList());
    if (!dimensionMeltdowns.isEmpty()) {
        dimensionMeltdowns.removeIf(meltdown -> meltdown.update(world));
        //If we have/had any meltdowns mark our data handler as dirty as when a meltdown updates
        // the number of ticks it has been around for will change
        markDirty();
    }
}

看起来每个 tick 都会对一个 Meltdown 执行 update,并根据返回值来判定是否要继续这个 Meltdown,那么我们看看 Meltdown 是怎么写的。

public boolean update(Level world) {
    ticksExisted++;

    if (world.random.nextInt() % 10 == 0 && world.random.nextDouble() < magnitude * chance) {
        int x = Mth.nextInt(world.random, minPos.getX(), maxPos.getX());
        int y = Mth.nextInt(world.random, minPos.getY(), maxPos.getY());
        int z = Mth.nextInt(world.random, minPos.getZ(), maxPos.getZ());
        createExplosion(world, x, y, z, radius, true, Explosion.BlockInteraction.DESTROY);
    }

    if (!WorldUtils.isBlockLoaded(world, minPos) || !WorldUtils.isBlockLoaded(world, maxPos)) {
        return true;
    }
   
    return ticksExisted >= DURATION;
}

其中 DURATION 是一个常量 100,magnitude 是熔毁时反应堆的温度。

可以发现,这个函数最多调用 100 次,随后就会返回 true 而将自己移出 meltdowns,不再调用此函数,这时我们说这个 Meltdown 失效了。

每 10 ticks,就会发生一次随机判定,这个概率是很简单的温度乘配置概率。通过简单的数论分析,我们会发现这个判定一定会在这个 Meltdown 失效前调用恰好 10 次。如果判定成功,就会在多方块结构中随机一个位置产生可破坏方块的爆炸。

配置概率是个常数,所以。。

爆炸概率=熔毁时反应堆温度 / 512000

由于熔毁时反应堆温度至少为 1200,于是爆炸概率至少为 0.00234375,然而在此情况下一次爆炸都不发生的概率为 97.68%。

private void createExplosion(Level world, double x, double y, double z, float radius, boolean causesFire, Explosion.BlockInteraction mode) {
    ...
   
    //Next go through the different locations that were inside our reactor that should have exploded and make sure
    // that if they didn't explode that we manually run the logic to make them "explode" so that the reactor stops
    //Note: Shuffle so that the drops don't end up all in one corner of an explosion
    Util.shuffle(toBlow, world.random);
    List<Pair<ItemStack, BlockPos>> drops = new ArrayList<>();
    for (BlockPos toExplode : toBlow) {
        BlockState state = world.getBlockState(toExplode);
        //If the block didn't already get broken when running the normal explosion
        if (!state.isAir()) {
            if (state.canDropFromExplosion(world, toExplode, explosion) && world instanceof ServerLevel level) {
                BlockEntity tileentity = state.hasBlockEntity() ? world.getBlockEntity(toExplode) : null;
                LootContext.Builder lootContextBuilder = new LootContext.Builder(level)
                      .withRandom(world.random)
                      .withParameter(LootContextParams.ORIGIN, Vec3.atCenterOf(toExplode))
                      .withParameter(LootContextParams.TOOL, ItemStack.EMPTY)
                      .withOptionalParameter(LootContextParams.BLOCK_ENTITY, tileentity)
                      .withOptionalParameter(LootContextParams.THIS_ENTITY, null);
                if (mode == Explosion.BlockInteraction.DESTROY) {
                    lootContextBuilder.withParameter(LootContextParams.EXPLOSION_RADIUS, radius);
                }
                state.getDrops(lootContextBuilder).forEach(stack -> addBlockDrops(drops, stack, toExplode));
            }
            state.onBlockExploded(world, toExplode, explosion);
        }
    }
    for (Pair<ItemStack, BlockPos> pair : drops) {
        Block.popResource(world, pair.getSecond(), pair.getFirst());
    }
}

爆炸发生后,如果反应堆没有炸干净,那么还会贴心的把反应堆拆掉(见注释)。

由于即使熔毁发生,也有相当大的概率使得反应堆不爆炸,所以我们最好是算一下爆炸概率。

一次爆炸概率 = 温度 / 512000 

十次连续不爆炸概率 = (1 - 一次爆炸概率) ^ 10

反应堆损坏的概率 = 熔毁概率 * (1 - 十次连续不爆炸概率) = (损坏值 / 100) * 配置熔毁概率 * (1 - 温度 / 512000) ^ 10 =>  损伤值 * 0.00001 * (1 - 温度 / 512000) ^ 10

注意,这个概率同样应该视作是每 tick 判定的,因为同一次熔毁条件内多次熔毁会算作多个 Meltdown 来进行判定。

在最低限度的熔毁条件下,每 tick 发生反应堆损坏的概率是 0.0977%,也就是说反应堆能活过 30 秒的概率高达 55.63%,活过一分钟的概率是 30.95%。

即使反应堆回到了非熔毁条件,也不意味着一定安全,因为一次熔毁有 5 秒时间判定,有可能在进入非熔毁条件前触发了熔毁,而熔毁在五秒后判定成功爆炸然后炸了反应堆。


0x04 降温橙色预警...对不起,拿错稿子了

距离完全搞懂熔毁相关机制只差最后一步了,那就是,温度是如何变化的。

温度由热量决定,所以我们先考虑热量如何决定温度。

反应堆使用的温度/热量管理类是 VariableHeatCapacitor,这个类的 getTemperature 函数继承自父亲 BasicHeatCapacitor,实现为:

温度 = 热量 / 热容

而热容由反应堆给出:

heatCapacitor.setHeatCapacity(MekanismGeneratorsConfig.generators.fissionCasingHeatCapacity.get() * locations.size(), true);

即,热容为多方块结构的方块数量乘上配置里的 casingHeatCapacity 配置项,前者即外壳数量,后者默认为 1000,于是就有了另一个教程贴中给出的:

热容 = 外壳方块数 * 1000 = (长 * 宽 * 高 - (长 - 2) * (宽 - 2) * (高 - 2)) * 1 000

在 Mekanism 中,热量变化由函数 handleHeat 处理,这个函数在此代码中有四次调用,我们分别解析。

@Override
public double simulateEnvironment() {
    double invConduction = HeatAPI.AIR_INVERSE_COEFFICIENT + (INVERSE_INSULATION_COEFFICIENT + INVERSE_CONDUCTION_COEFFICIENT);
    double tempToTransfer = (heatCapacitor.getTemperature() - biomeAmbientTemp) / invConduction;
    heatCapacitor.handleHeat(-tempToTransfer * heatCapacitor.getHeatCapacity());
    return Math.max(tempToTransfer, 0);
}

环境传温,这个函数每 tick 调用一次,根据当前温度与环境温度,将当前温度逐渐调整至环境温度。二者之差越小,调整幅度越小。

调整幅度 = |(反应堆温度 - 环境温度) / 20010|,加绝对值是因为讨论正负号不好看,所以改成了没有正负号的「幅度」。

private void handleCoolant() {
    double temp = heatCapacitor.getTemperature();
    double heat = getBoilEfficiency() * (temp - HeatUtils.BASE_BOIL_TEMP) * heatCapacitor.getHeatCapacity();
    long coolantHeated = 0;
   
    if (!fluidCoolantTank.isEmpty()) {
        double caseCoolantHeat = heat * waterConductivity;
        coolantHeated = (int) (HeatUtils.getSteamEnergyEfficiency() * caseCoolantHeat / HeatUtils.getWaterThermalEnthalpy());
        coolantHeated = Mth.clamp(coolantHeated, 0, fluidCoolantTank.getFluidAmount());
        if (coolantHeated > 0) {
            MekanismUtils.logMismatchedStackSize(fluidCoolantTank.shrinkStack((int) coolantHeated, Action.EXECUTE), coolantHeated);
            // extra steam is dumped
            heatedCoolantTank.insert(MekanismGases.STEAM.getStack(coolantHeated), Action.EXECUTE, AutomationType.INTERNAL);
            caseCoolantHeat = coolantHeated * HeatUtils.getWaterThermalEnthalpy() / HeatUtils.getSteamEnergyEfficiency();
            heatCapacitor.handleHeat(-caseCoolantHeat);
        }
    } else if (!gasCoolantTank.isEmpty()) {
        CooledCoolant coolantType = gasCoolantTank.getStack().get(CooledCoolant.class);
        if (coolantType != null) {
            double caseCoolantHeat = heat * coolantType.getConductivity();
            coolantHeated = (int) (caseCoolantHeat / coolantType.getThermalEnthalpy());
            coolantHeated = Mth.clamp(coolantHeated, 0, gasCoolantTank.getStored());
            if (coolantHeated > 0) {
                MekanismUtils.logMismatchedStackSize(gasCoolantTank.shrinkStack((int) coolantHeated, Action.EXECUTE), coolantHeated);
                heatedCoolantTank.insert(coolantType.getHeatedGas().getStack(coolantHeated), Action.EXECUTE, AutomationType.INTERNAL);
                caseCoolantHeat = coolantHeated * coolantType.getThermalEnthalpy();
                heatCapacitor.handleHeat(-caseCoolantHeat);
            }
        }
    }
    lastBoilRate = coolantHeated;
}

冷却剂控温,调用了函数 getBoilEfficiency。

@ComputerMethod
public double getBoilEfficiency() {
    double avgSurfaceArea = (double) surfaceArea / (double) fuelAssemblies;
    return Math.min(1, avgSurfaceArea / MekanismGeneratorsConfig.generators.fissionSurfaceAreaTarget.get());
}

surfaceArea 与 fuelAssemblies 由另一个函数计算。

@Override
public FormationResult postcheck(FissionReactorMultiblockData structure, Long2ObjectMap<ChunkAccess> chunkMap) {
    Map<AssemblyPos, FuelAssembly> map = new HashMap<>();
    Set<BlockPos> fuelAssemblyCoords = new HashSet<>();
    int assemblyCount = 0, surfaceArea = 0;

    for (BlockPos coord : structure.internalLocations) {
        BlockEntity tile = WorldUtils.getTileEntity(world, chunkMap, coord);
        AssemblyPos pos = new AssemblyPos(coord.getX(), coord.getZ());
        FuelAssembly assembly = map.get(pos);

        if (tile instanceof TileEntityFissionFuelAssembly) {
            if (assembly == null) {
                map.put(pos, new FuelAssembly(coord, false));
            } else {
                assembly.fuelAssemblies.add(coord);
            }
            assemblyCount++;
            // compute surface area
            surfaceArea += 6;
            for (Direction side : EnumUtils.DIRECTIONS) {
                if (fuelAssemblyCoords.contains(coord.relative(side))) {
                    surfaceArea -= 2;
                }
            }
            fuelAssemblyCoords.add(coord);
        }
    ..
    }
    ...
}

遍历每一个燃料组件,每个组件提供 6 个表面积,遍历它的六个面,如果与之前遍历过的组件相邻则扣除 2 个表面积(它和相邻组件的),同时,每个组件提供 1 个组件计数。

于是 surfaceArea 就是燃料组件的表面积,fuelAssemblies 就是燃料组件的个数。

所以 getBoilEfficiency 的返回值就是 表面积 / 燃料组件 / 配置中的 getBoilEfficiency,而最后一个通常是 4,这就是另一个教程贴中的:

沸腾效率 = min(1, 燃料组件表面积 / 燃料组件数 / 4)

接下来要分两种情况讨论,如果冷却剂是水,经过一番计算:

热量变化 = -clamp(沸腾效率 * (温度 - 373.15) * 热容 / 100, 0, 水量) * 50

clamp(x, a, b) 表示 min(max(x, a), b),也就是把 x 夹到 [a, b] 中间。

如果冷却剂是钠蒸汽,经过一番计算:

热量变化 = -clamp(沸腾效率 * (温度 - 373.15) * 热容 / 5, 0, gasCoolantTank.getStored()) * 5

这两次 /100 和 /5 都会进行一次下取整,这对于钠蒸汽来说表明计算过程中亏损的热量变化绝对值更小。

尽管一看就知道钠的热量变化在水的两倍左右。

private void burnFuel(Level world) {
    ...
   
    double storedFuel = fuelTank.getStored() + burnRemaining;
    double toBurn = Math.min(Math.min(rateLimit, storedFuel), fuelAssemblies * MekanismGeneratorsConfig.generators.burnPerAssembly.get());
   
    ...

    burnRemaining = storedFuel % 1;
    heatCapacitor.handleHeat(toBurn * MekanismGeneratorsConfig.generators.energyPerFissionFuel.get().doubleValue());
   
    ...
}

这是控制热量提高的逻辑。其中的 rateLimit 是玩家自己设置的速率限制。MekanismGeneratorsConfig.generators.burnPerAssembly.get() 是配置的每燃料组件效率,默认为 1。

燃料消耗 = min(速率限制, 存储的燃料, 燃料组件个数 * 配置的每燃料组件速率) => min(速率限制, 存储的燃料, 燃料组件个数)

热量变化 = 燃料消耗 * 配置的每燃料效率(默认为 1000000) => 1000000 * 燃料消耗


与损伤值不同,温度越高,散热越快,因此温度可能会保持在某个很高的点而不再有可见的上升。

冷却剂、燃料、速率限制都会影响输入的消耗,但玩家可控的只有冷却剂的输入和速率限制(毕竟虽然燃料很少的时候燃料消耗也很低,但我寻思你填个速率限制更方便)。

温度对实际运行效率没有任何影响,如果是第一次玩这玩意可以选择无脑压低。


裂变反应堆的核心机制到这里就介绍完毕了,后面是一些与其有关的坑。


0x05 盖革计数器在响啊啊啊啊啊啊啊啊啊!!!!!!!!!

这一部分是关于辐射机制的,之后再填坑。


0x06 这是什么,32,输一下,反应堆炸了

这一部分是关于调整速率限制的,之后再填坑。