Villager Eating Priority

I was trying to understand how villagers prioritise eating different foods. From looking at the game code, it seems that the game randomly reorders all the food items into a new priority list every time they restock their inventory, and the villager then goes down that list, picking up as much as possible from the current location, until their inventory is full. [I’m not entirely sure if the same logic is used when a residence sends a logistics request to be stocked with food.]

This means that they might pick up (eg) greens, mushrooms, and preserved vegetables, while ignoring all proteins, fruits, grain, and dairy completely. Considering having a variety of food types is a big part of upgrading houses, this seems counter intuitive to how they should behave, and part of the reason why houses get randomly stuck unable to upgrade at times.

So I decided to try to mod it to see what happened. I kept the same basic logic, but rather than completely randomising the priority, I made sure that it alternated between the different food types (vegetables, fruit, protein, dairy, grain) as far as possible. This means that the top 5 priority items will always cover all the food types, and so also that food items that belong to a type with little variety (eg, the only grains are bread and pastry) are more likely to be at the top of the list than those with lots of variety (like vegetables and proteins).

I left a game running for a year (well developed T5 town with pop almost constant at 1500) to see what the impact would be, here’s the results (it’s the year 43 → 44 that matters)


2
3
4
5

As expected there’s a big uptick in Grain and Dairy consumption, a moderate drop in Protein and Veg, and Fruit stays the same. These aren’t huge changes (except for Dairy, but I think my herds were growing at the same time), only about 10-20%, because things are still constrained by what a market happens to be stocking at any given time, but it’s still sizeable. I also saw a whole bunch of houses that become eligible to upgrade, because getting the required food diversity is less of a lottery than before.

Here’s the functional part of the code for a Mono mod, I use harmony to effectively override the Villager.randomizedFoodTypes getter. The rest of it implements the partial randomisation described above.

    [HarmonyPatch(typeof(Villager), "randomizedFoodTypes", MethodType.Getter)]
    public static class PatchedVillager
    {

        // Prefix that skips the original method altogether
        public static bool Prefix(ref List<Item> __result)
        {
            __result = RandomizeFoodByNutritionTypes();
            return false;
        }
 
        public static List<Item> RandomizeFoodByNutritionTypes()
        {
            // Get a random list of food items, but going through the nutritional types in turn
            var randomizedFoodItems = new List<Item>();

            // Randomise the foods within each type (first making a deep copy)
            var foodRemaining = foodItemsByType.ToDictionary(entry => entry.Key, entry => entry.Value.ToList());
            foreach (var item in foodRemaining) { item.Value.Randomize(); }

            // Build the output item by item
            var typesCycle = new List<Villager.FoodType>();
            while (foodRemaining.Count > 0)
            {
                // Pick a type that hasn't been used since the last cycle, starting a new cycle if necessary
                if (typesCycle.Count == 0)
                {
                    typesCycle = new List<Villager.FoodType>(foodRemaining.Keys);
                    typesCycle.Randomize(null);
                }
                Villager.FoodType currType = typesCycle[0];
                typesCycle.RemoveAt(0);

                // Pick a food item from that type, if that leaves it empty don't look at it again
                randomizedFoodItems.Add(foodRemaining[currType][0]);
                foodRemaining[currType].RemoveAt(0);
                if (foodRemaining[currType].Count == 0)
                {
                    foodRemaining.Remove(currType);
                }
            }
            return randomizedFoodItems;
        }


        // The inverse of Villager.foodNameToFoodTypeDict
        public static readonly Dictionary<Villager.FoodType, List<Item>> foodItemsByType = new Dictionary<Villager.FoodType, List<Item>>
        {
            { Villager.FoodType.foodTypeProtein,
                new List<Item>() {new ItemMeat(), new ItemFish(), new ItemSmokedMeat(), new ItemSmokedFish(), new ItemEggs() }
            },

            { Villager.FoodType.foodTypeGrain,
                new List<Item>() {new ItemBread(), new ItemPastry() }
            },

            { Villager.FoodType.foodTypeVegetable,
                new List<Item>() { new ItemBeans(), new ItemRootVegetable(), new ItemMushroom(), new ItemPreservedVeg(), new ItemGreens() }
            },

            { Villager.FoodType.foodTypeFruit,
                new List<Item>() { new ItemBerries(), new ItemNuts(), new ItemFruit(), new ItemPreserves() }
            },

            { Villager.FoodType.foodTypeDairy,
                new List<Item>() { new ItemMilk(), new ItemCheese() }
            },
        };
    }

Not the flashiest mod by any stretch, and I haven’t played a full game with it yet, but it feels like a general upgrade to villager AI behaviour.

6 Likes

This is really fascinating. Thanks for sharing. Makes a lot more sense in my opinion to make sure the food groups are covered, especially when available. If you upload it to the workshop or Nexus I would be happy to try it and provide feedback.

1 Like

Agree to post it on the Nexus, especially once 0.9.7 goes live. :slight_smile:

Very interesting indeed! Maybe I’ll tweak around with game mechanics myself a bit in the future. :slight_smile:

Also, not sure if I’ve ever seen such a well commented code! Did you do the comments, or were they already there und you “just” tweaked them?

Ha, that’s the first time I’ve ever had that compliment! I made an effort here because I wanted it to be of some teaching value to other would-be modders. All the code there I wrote myself, the original code is in Villager.randomizedFoodTypes.

The way modding works is that you can’t actually touch the original code, that’s wrapped in a compiled (well, JIT compiled as C# is anyway) and it’s strictly read only. Instead, mods add new code as an additional layer, which “listens” to the original code and steps in at the right time using runtime reflection black magic handled by Harmony and MelonLoader. What the harmony patch above does is replace every called to Villager.randomizedFoodTypes with my new PatchedVillager.RandomizedFoodByNutritionTypes. Hopefully that makes sense if you’re familiar with Object Oriented Programing.

As for sharing the mod, I will do seeing as there’s some interest. Although this requires the mono build of the game, which is currently only open through the public playtest, so it may not be quite appropriate yet.

I’ve also been doing some more digging, and I think the code used to restock shelters by market workers is different. Rather than going through the food items in a random priority order and taking as much from each as possible, it tries to take an equal amount from all the different food items available at the market. Changing that to take an equal amount from every food nutrition type should enhance the effect of the existing change. But the way the game handles logistics request is hugely complicated and I’m having trouble both understanding what it does and injecting changes at the right place.

If any devs are reading this and want to explain LogisticsGlobalQueryJob.LogisticsGlobalQueryJob.DetermineMultiItemSelection() to me, especially what it’s arguments come from and what they mean, I would be immensely grateful.

1 Like

It took me an accidental all nighter, but I’ve managed to change the restocking shelters logic. I don’t know what kind of Faustian pact the dev who wrote the Logistics part of the game made, but I’m in equal parts awed and terrified.

To summarise the changes I’ve made:

  • When villagers eat, they used to pick one random FoodItem (eg, Nuts or Smoked Meat or Bread) at a time and see if it was available where they were. Now, they do so while cycling between different FoodTypes (eg, Grain or Veg or Protein) as far as possible. Long story short, this means that Bread or Cheese are far more likely to be the first FoodItem they try to eat and guaranteed to be in the first 10.

  • When villagers restock shelters, they used to take an equal quantity of every FoodItem available at the storage location they were restocking from. Now, they’ll take proportionally more of a FoodItem if it belongs to a FoodType with few items. In practice, this means that when they pick up Bread / Milk / Cheese / Pastries, they’ll pick up twice as much as before.

This second change is the new one, and it results in villagers doing things like this:


Surprisingly, it had only a modest impact on the total grain and dairy consumption compared to the first change. Maybe because I have production / storage bottlenecks elsewhere, or it takes more than a year for the stockpiles of shelters to completely cycle through, or the decision about which buildings to restock from (ie, cellars rather than bakeries) is made before this one and dominates. Still, I’m curious what impact it has on a full game and whether it feels like a general improvement or not.

If you want to beta test it for me, here is the .dll:
BalancedDiet.zip (3.9 KB)
Unzip and place it so that your directory is Farthest Frontier\Farthest Frontier (Mono)\Mods\BalancedDiet.dll. This requires running the game version 0.9.7P3 and in mono. If you don’t see a Mods file, run the game in mono once and then exit. Note that there’s the distinct possibility that it might have weird side effects or a small memory leak, so make a back up save just in case. But it is compatible with existing save files and vice versa (if you delete the mod you’ll be able to continue the same settlement with the vanilla game). Let me know how it goes for you!

Because this subforum is still in its infancy, I’ll share the commented source code again in case it’s a useful example for others trying to do similar things.

Summary
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Collections;
using MelonLoader;
using HarmonyLib;

namespace BalancedDiet
{
    public class BalancedDietMod : MelonMod
    {
        public override void OnInitializeMelon()
        {
            LoggerInstance.Msg("Initialized");
#if DEBUG
                FileLog.Log("Initialized");
#endif
        }
    }

    // Makes villagers pick food (for their personal inventory and to eat) uniformly from FoodTypes rather than FoodItems
    [HarmonyPatch(typeof(Villager), "randomizedFoodTypes", MethodType.Getter)]
    public static class PatchedVillager
    {
        // Prefix that skips the original method altogether
        public static bool Prefix(ref List<Item> __result)
        {
#if DEBUG
                FileLog.Log("Prefixed replaced Villager.randomizedFoodTypes");
#endif
            __result = RandomizeFoodByNutritionTypes();
            return false;
        }

        public static List<Item> RandomizeFoodByNutritionTypes()
        {
            // Get a random list of FoodItems, but going through the FoodTypes in turn
            var randomizedFoodItems = new List<Item>();

            // Randomise the foods within each type (first making a deep copy)
            var foodRemaining = FoodItemsByType.ToDictionary(entry => entry.Key, entry => entry.Value.ToList());
            foreach (var item in foodRemaining) { item.Value.Randomize(); }

            // Build the output item by item
            var typesCycle = new List<Villager.FoodType>();
            while (foodRemaining.Count > 0)
            {
                // Pick a type that hasn't been used since the last cycle, starting a new cycle if necessary
                if (typesCycle.Count == 0)
                {
                    typesCycle = new List<Villager.FoodType>(foodRemaining.Keys);
                    typesCycle.Randomize(null);
                }
                Villager.FoodType currType = typesCycle[0];
                typesCycle.RemoveAt(0);

                // Pick a food item from that type, if that leaves it empty don't look at it again
                randomizedFoodItems.Add(foodRemaining[currType][0]);
                foodRemaining[currType].RemoveAt(0);
                if (foodRemaining[currType].Count == 0)
                {
                    foodRemaining.Remove(currType);
                }
            }
            return randomizedFoodItems;
        }

        // List the all the FoodItems of a given FoodType
        private static Dictionary<Villager.FoodType, List<Item>> _foodItemsByType;

        public static Dictionary<Villager.FoodType, List<Item>> FoodItemsByType
        {
            get
            {
                if (_foodItemsByType == null)
                {
                    _foodItemsByType = new Dictionary<Villager.FoodType, List<Item>>();
                    foreach (var foodType in Villager.foodTypes)
                    {
                        _foodItemsByType[foodType] = new List<Item>();
                    }
                    foreach (var foodItem in Villager.foodItems)
                    {
                        Villager.FoodType foodType = Villager.foodNameToFoodTypeDict[foodItem.name];
                        _foodItemsByType[foodType].Add(foodItem);
                    }
                }
                return _foodItemsByType;
            }
        }
    }


    // Makes villagers pick up more FoodItems from the FoodTypes with few items in them when restocking residences
    [HarmonyPatch(typeof(LogisticsGlobalTaskSearch), "SetupImmutableMultiItemLists")]
    public class PatchedLogistics
    {
        // Update the list of FoodItemIDs to deliver that LogisticsGlobalTaskSearch maintains 
        public static void Postfix(LogisticsGlobalTaskSearch __instance)
        {
#if DEBUG
                FileLog.Log("Postfix applied to LogisticsGlobalTaskSearch");
#endif
            // Cheeky reflection to get at private member
            FieldInfo field = typeof(LogisticsGlobalTaskSearch).GetField("multiItemLists", BindingFlags.Instance | BindingFlags.NonPublic);
            var multiItemLists = (NativeList<UnsafeList<int>>)field.GetValue(__instance);  // If this throws a compiler error, upgrade project to C#8.0+ 
            if (multiItemLists.Length >= 1)
            {
                // Cleanly dispose of original before inserting replacement
                if (multiItemLists[0].IsCreated)
                {
                    multiItemLists[0].Dispose();
                }
                multiItemLists[0] = FoodItemIDsBiasedByType();
            }
        }

        public static UnsafeList<int> FoodItemIDsBiasedByType()
        {
            // Flattens FoodItemsByType to the required Unsafe list
            // by repeating items from FoodTypes that have a whole multiple fewer items than the largest
            int numFoodTypes = PatchedVillager.FoodItemsByType.Count;
            int largestFoodType = PatchedVillager.FoodItemsByType.Max(x => x.Value.Count);

            var foodItemsBiased = new UnsafeList<int>(numFoodTypes * largestFoodType, AllocatorManager.Persistent, NativeArrayOptions.UninitializedMemory);
            foreach (var foodIDsOfType in PatchedVillager.FoodItemsByType)
            {
                int repeats = largestFoodType / foodIDsOfType.Value.Count;
                for (int _ = 0; _ < repeats; _++)
                {
                    foreach (var foodItem in foodIDsOfType.Value)
                    {
                        foodItemsBiased.Add((int)foodItem.itemID);
                    }
                }
            }
            return foodItemsBiased;
        }
    }

}
4 Likes

Is this normal? MelonLoader marks this dll in red, while other mods are listed in pale blue.

dd

That’s perfectly normal - each mod can decide it’s own colour. I picked red so I can easily distinguish it from the other stuff I’m working on. But point noted, I’ll change the colour in the future to something a little less worrying :slight_smile:

1 Like