引言

无论是在中文论坛还是英语论坛, 都会看到一个说法

image-20250303173751793

即如果第一个怪物是Cultist(邪教徒Kakka), 则有80%的概率, 你的下一个事件(问号)会变成战斗, 导致你无法使用捏奥的悲恸白嫖到精英.

这个让我很是奇怪, 在我之前的认知中, 杀戮尖塔遇到什么怪物, 问号碰到什么事件, 都是已经注定好的行为, 为什么会有Bug会让第一支怪物, 影响到后续的问号事件呢?

所幸杀戮尖塔是用Java写的, 所以我们可以很直接的反编译他的Jar文件, 看到源代码

源代码获取方式

打开杀戮尖塔文件夹, 里面会有一个名为desktop-1.0.jar的文件, 使用任意反编译工具即可获取源代码.

这里我使用的是JD-GUI, 将其解析之后获得到源码

image-20250303193441285

生成怪物和事件的关系

生成怪物

其中生成怪物的代码在desktop-1.0.jar.src\com\megacrit\cardcrawl\dungeons\Exordium.java

protected void generateMonsters() {
    generateWeakEnemies(3);
    generateStrongEnemies(12);
    generateElites(10);
}

这里是Exordium, 即第一章序幕也就是第一层, 会生成3个弱怪, 12个强怪, 10个精英.

这里可以证明, 3个弱怪之后就是强怪池.

其中生成弱怪的代码

protected void generateWeakEnemies(int count) {
    ArrayList<MonsterInfo> monsters = new ArrayList<>();
    monsters.add(new MonsterInfo("Cultist", 2.0F));
    monsters.add(new MonsterInfo("Jaw Worm", 2.0F));
    monsters.add(new MonsterInfo("2 Louse", 2.0F));
    monsters.add(new MonsterInfo("Small Slimes", 2.0F));
    MonsterInfo.normalizeWeights(monsters);
    populateMonsterList(monsters, count, false);
}

其中MonsterInfo中, 第一个参数为怪物名称, 第二个是生成权重, 这里面四个怪物的权重是一样的, 所以理论上生成的概率是相同的.

其中MonsterInfo.normalizeWeights(monsters);具体为

public static void normalizeWeights(ArrayList<MonsterInfo> list) {
	Collections.sort(list);
    float total = 0.0F;
    for (MonsterInfo i : list){
        total += i.weight; 
    }
    for (MonsterInfo i : list) {
		i.weight /= total;
		if (Settings.isInfo){
            logger.info(i.name + ": " + i.weight + "%"); 
        }
    } 
}

可得, 每个弱怪池中的怪物权重均为$\frac{2}{(2+2+2+2)}=\frac{1}{4}$

public void populateMonsterList(ArrayList<MonsterInfo> monsters, int numMonsters, boolean elites) {
    if (eliteMonsterList.isEmpty()) {
        // 如果列表为空,直接添加
        eliteMonsterList.add(MonsterInfo.roll(monsters, monsterRng.random()));
    } else {
        // 确保新生成的怪物与列表中最后一个不同
        String toAdd = MonsterInfo.roll(monsters, monsterRng.random());
        if (!toAdd.equals(eliteMonsterList.get(eliteMonsterList.size() - 1))) {
            eliteMonsterList.add(toAdd);
        } else {
            i--; // 重复则重试
        }
    }
    if (monsterList.isEmpty()) {
        // 如果列表为空,直接添加
        monsterList.add(MonsterInfo.roll(monsters, monsterRng.random()));
    } else {
        String toAdd = MonsterInfo.roll(monsters, monsterRng.random());
        // 检查与最后一个怪物是否相同
        if (!toAdd.equals(monsterList.get(monsterList.size() - 1))) {
            // 如果列表长度>1,还要检查与倒数第二个怪物是否相同
            if (monsterList.size() > 1 && toAdd.equals(monsterList.get(monsterList.size() - 2))) {
                i--; // 与倒数第二个相同则重试
            } else {
                monsterList.add(toAdd);
            }
        } else {
            i--; // 与最后一个相同则重试
        }
    }

可以看出,

MonsterInfo.roll(monsters, monsterRng.random());

决定了最终添加什么怪物, 也就是monsterRng.random()的随机数决定了添加什么怪物, 其调用的random函数为desktop-1.0.jar.src\com\megacrit\cardcrawl\random\Random.java

public float random() {
    this.counter++;
    return this.random.nextFloat();
}

上文提到, i.weight在第一次刷新怪物的时候, 均为$\frac{1}{4}$, 同时MonsterInfo这个ArrayList的怪物顺序为

["Cultist","Jaw Worm","2 Louse","Small Slimes"]

如果第一只是Cultist, 则可知随机数的结果是0.00-0.25

随机数如何产生

种子

未指定种子的时候, 随机生成一个种子

  private void setRandomSeed() {
    // 使用系统时间作为源种子
    long sourceTime = System.nanoTime();
    Random rng = new Random(Long.valueOf(sourceTime));
    Settings.seedSourceTimestamp = sourceTime;
    // 生成一个"不冒犯"的种子(不含敏感词)
    Settings.seed = Long.valueOf(SeedHelper.generateUnoffensiveSeed(rng));
    Settings.seedSet = false;
  }
  public static long generateUnoffensiveSeed(Random rng) {
    String safeString = "fuck";
    while (BadWordChecker.containsBadWord(safeString) || TrialHelper.isTrialSeed(safeString)) { //生成的时候不但会考虑是否包含不文明的词汇, 还要考虑种子是否是特殊试炼的种子
      long possible = rng.randomLong();
      safeString = getString(possible);
    } 
    return getLong(safeString);
  }

或者是用户手动输入一个种子

  public static void setSeed(String seedStr) {
    if (seedStr.isEmpty()) {
      Settings.seedSet = false;
      Settings.seed = null;
      Settings.specialSeed = null;
    } else {
      long seed = getLong(seedStr);
      Settings.seedSet = true;
      Settings.seed = Long.valueOf(seed);
      Settings.specialSeed = null;
      Settings.isDailyRun = false;
      cachedSeed = null;
    } 
  }

在我们选择角色的时候(desktop-1.0.jar.src\com\megacrit\cardcrawl\screens\charSelect\CharacterSelectScreen.java)

AbstractDungeon.generateSeeds();
  public static void generateSeeds() {
    logger.info("Generating seeds: " + Settings.seed);
    monsterRng = new Random(Settings.seed); //怪物
    eventRng = new Random(Settings.seed); //事件
    merchantRng = new Random(Settings.seed); //商店
    cardRng = new Random(Settings.seed); //遇到的卡片
    treasureRng = new Random(Settings.seed); //宝箱
    relicRng = new Random(Settings.seed); //遗物
    monsterHpRng = new Random(Settings.seed); //怪物血量
    potionRng = new Random(Settings.seed); //药水
    aiRng = new Random(Settings.seed); //怪物的策略ai
    shuffleRng = new Random(Settings.seed); //排序/洗牌
    cardRandomRng = new Random(Settings.seed); //卡牌的随即效果(比如跳瓶)
    miscRng = new Random(Settings.seed); //杂项
  }

我们会将种子作为基准种子传入很多随机数生成类, 这样保证了我们使用相同的种子, 开始的随机数总是相同的.

最后通过

  public void setSeed(long seed) {
    long seed0 = murmurHash3((seed == 0L) ? Long.MIN_VALUE : seed);
    setState(seed0, murmurHash3(seed0));
  }
  private static final long murmurHash3(long x) {
    x ^= x >>> 33L;
    x *= -49064778989728563L;
    x ^= x >>> 33L;
    x *= -4265267296055464877L;
    x ^= x >>> 33L;
    return x;
  }

murmurHash3算法生成一个seed0作为实际计算随机数时使用的数字.

  public void setSeed(long seed) {
    long seed0 = murmurHash3((seed == 0L) ? Long.MIN_VALUE : seed);
    setState(seed0, murmurHash3(seed0));
  }
  
  public void setState(long seed0, long seed1) {
    this.seed0 = seed0;
    this.seed1 = seed1;
  }

seed1seed0再经过一次murmurHash3得到的新数字, 由此, 我们获取了我们未来游戏中所有需要随机数的地方, 作为基准使用的两个变量seed0seed1.

这里使用的是xoshiro128***算法, 其缺点就是

  • 如果内部状态被泄露,未来的输出可被完全预测
  • 对于某些变体,可以通过连续输出推断内部状态

也就是说, 这种算法的每一次随机, 其实不是通过真的随机生成, 而是通过最开始的基准数字, 在经过一系列的数学计算, 得到的随机数, 因此这一次获得的结果, 与上一次是深度耦合的.

因此, 之前出现了不少nosl预测下一个随机数, 以及尝试不同的出牌方式来寻找不同的世界线的sl玩法, 甚至有mod可以使得这个失效https://steamcommunity.com/sharedfiles/filedetails/?id=2181005326

其原理就是侵入式修改不同的事件的基底随机数, 使得不能被互相耦合对应

遭遇怪物

根据上面的结论, 如果我们第一个遇到的怪物是Cultist, 那么生成怪物时第一个随机数结果会是0.00-0.25之前的浮点数.

同时根据上面的代码可知, 每一个random.nextFloat();第一个的结果都会是一样的

问号生成什么事件代码在desktop-1.0.jar.src\com\megacrit\cardcrawl\helpers\EventHelper.java

//初始化遇到各事件的概率
private static float ELITE_CHANCE = 0.1F;
private static float MONSTER_CHANCE = 0.1F;
private static float SHOP_CHANCE = 0.03F;
public static float TREASURE_CHANCE = 0.02F;

public enum RoomResult {
    EVENT, ELITE, TREASURE, SHOP, MONSTER;
  }

if (AbstractDungeon.floorNum < 6) //如果层数<6, 则不会在问号中遇到精英
  eliteSize = 0;
int monsterSize = (int) (MONSTER_CHANCE * 100.0F); //10
int shopSize = (int) (SHOP_CHANCE * 100.0F); //3
int treasureSize = (int) (TREASURE_CHANCE * 100.0F); //2
if (AbstractDungeon.getCurrRoom() instanceof com.megacrit.cardcrawl.rooms.ShopRoom) //如果走过了一层商店, 那么将商店的概率清零
  shopSize = 0;
int fillIndex = 0;
RoomResult[] possibleResults = new RoomResult[100];
Arrays.fill((Object[]) possibleResults, RoomResult.EVENT); //先将所有可能填充为"事件"

Arrays.fill((Object[]) possibleResults,Math.min(99, fillIndex),Math.min(100, fillIndex + monsterSize), RoomResult.MONSTER); //填充0->(0+10)为怪物事件

fillIndex += monsterSize; //fillIndex=0+10=10

Arrays.fill((Object[]) possibleResults, Math.min(99, fillIndex), Math.min(100, fillIndex + shopSize),RoomResult.SHOP); //填充10->(10+3)为商店事件

fillIndex += shopSize; //fillIndex=10+3=13
Arrays.fill((Object[]) possibleResults,Math.min(99, fillIndex),Math.min(100, fillIndex + treasureSize), RoomResult.TREASURE); //填充13->(13+2)为宝箱事件


RoomResult choice = possibleResults[(int) (roll * 100.0F)];

最后我们的RoomResult应该为

0->10(不包含): Monster
10->13(不包含): Shop
13->15(不包含): Treasure
16->100(不包含): Event

[MONSTER, MONSTER, MONSTER, MONSTER, MONSTER, MONSTER, MONSTER, MONSTER, MONSTER, MONSTER,
SHOP, SHOP, SHOP,
TREASURE, TREASURE,
EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT, EVENT]

结论

因为最开始的随机数都是按照种子来获得的, 则每一个随机数最开始的一个结果都是一致的.

那么当我们遇到Cultist时, roll为[0.00,0.25], 而根据RoomResult中Monster的分布位置来看, 只有在roll为[0.00,0.10]时我们才会遇到怪物, 概率为 $$ \frac{0.1}{0.25}=0.4 $$ 并不是网上传言的80%.

同时可以得出另一个结论, 当我们第一个遇到的怪物不是Cultist时, 我们的第一个事件(没有任何遗物影响的情况下), 必然不会是遭遇怪物.

当然这个结论是在不开启任何mod, 正常游戏而非无尽模式的情况下得到的, 如果有其他额外因素, 结论会发生变化.