diff --git a/Assemblies/PoisonedFoodStackFix.dll b/Assemblies/PoisonedFoodStackFix.dll index 0e12b8a..8b9bf94 100644 Binary files a/Assemblies/PoisonedFoodStackFix.dll and b/Assemblies/PoisonedFoodStackFix.dll differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e826a1 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# Poisoned Food Stack Fix by Binch +RimWorld mod to fix how food poisoning chance is calculated for stacks. + +Published in Steam Workshop: https://steamcommunity.com/sharedfiles/filedetails/?id=2921755685 diff --git a/Source/PoisonedFoodStackFix/HarmonyPatches.cs b/Source/PoisonedFoodStackFix/HarmonyPatches.cs index 994ed3c..bc93278 100644 --- a/Source/PoisonedFoodStackFix/HarmonyPatches.cs +++ b/Source/PoisonedFoodStackFix/HarmonyPatches.cs @@ -6,6 +6,175 @@ namespace PoisonedFoodStackFix { + /// + /// Hypergeometric distribution. + /// + public static class Hypergeometric + { + /// + /// Random generation for the Hypergeometric(N, K, n) distribution.
+ /// An urn has N balls, K are green, n are drawn. How many green balls are drawn?
+ /// Valid parameters:
+ /// 0 ≤ N ≤ int.MaxValue
+ /// 0 ≤ K ≤ N
+ /// 0 ≤ n ≤ N
+ /// Warning: validity of parameters is not checked.
+ /// Performance note. Hypergeometric(1M, 500K, 500K) is generated in ≈31 ms on a 2.7 GHz CPU. + ///
+ /// Number of balls in urn. + /// Number of green balls in urn. + /// Number of drawn balls. + /// Number of drawn green balls. + public static int Generate(int N, int K, int n) + { + // Let pmf(k, N, K, n) be probability mass function for Hypergeometric(N, K, n). + // Using symmetry of pmf, it is possible to optimize the algorythm, aiming for smaller value of drawn balls. + + // pmf(k1, N, K, n) = pmf(n - k1, N, N - K, n) + // If number of non-green balls is less than number of green ones, swap colors. + // k1 is number of drawn green balls, the future return value of the function. + int G = N - K; + bool swapColors = G < K; + if (!swapColors) G = K; + + // pmf(k2, N, G, n) = pmf(G - k2, N, G, N - n) + // If number of left balls is less than number of drawn ones, swap sides, draw the number of balls that would be left. + // k2 is number of drawn green balls, considering a possible color swap. + int nn = N - n; + bool swapSides = nn < n; + if (!swapSides) nn = n; + + // Number of green balls left in the urn. Extra variable is created, because the value of G should be preserved. + int g = G; + // pmf(k, N, g, nn) = pmf(k, N, nn, g) + // If number of green balls is less than number of drawn ones, swap roles, swapping these numbers. + // k is number of drawn green balls. It is not affected by the swap. + if (g < nn) (g, nn) = (nn, g); + + // At this point, 0 ≤ nn ≤ g ≤ (N / 2), and the distribution to generate is Hypergeometric(N, g, nn). + + // k is number of drawn green balls, considering all possible swaps. + // When values of k2 and k1 will be calculated, they will also be stored here for simplicity. + int k = 0; + while (--nn >= 0) + { + if (Rand.Chance((float)g / N)) + { + --g; + ++k; + } + --N; + } + + // Revert symmetry swaps, if any. + if (swapSides) k = G - k; // k has value of k2 now. + if (swapColors) k = n - k; // k has value of k1 now. + + return k; + } + +#if DEBUG + // Test case description. + private struct TestCase + { + // Parameters of distribution. + public int N, K, n; + // Number of runs to perform. + public int runs; + } + // Pre-defined test cases. + private static readonly TestCase[] testCases = new TestCase[] + { + new TestCase() { N = 0, K = 0, n = 0, runs = 2 }, + + new TestCase() { N = 10, K = 5, n = 5, runs = 1000 }, + + // These tests should trigger all 8 permutations of symmetry swaps in Generate(). + new TestCase() { N = 10, K = 2, n = 4, runs = 10_000 }, + new TestCase() { N = 10, K = 2, n = 6, runs = 10_000 }, + new TestCase() { N = 10, K = 8, n = 4, runs = 10_000 }, + new TestCase() { N = 10, K = 8, n = 6, runs = 10_000 }, + new TestCase() { N = 10, K = 4, n = 2, runs = 10_000 }, + new TestCase() { N = 10, K = 4, n = 8, runs = 10_000 }, + new TestCase() { N = 10, K = 6, n = 2, runs = 10_000 }, + new TestCase() { N = 10, K = 6, n = 8, runs = 10_000 }, + + new TestCase() { N = 35, K = 1, n = 19, runs = 1000 }, + new TestCase() { N = 44, K = 4, n = 29, runs = 1000 }, + + new TestCase() { N = 10_000, K = 9900, n = 9000, runs = 100_000 }, + new TestCase() { N = 1_000_000, K = 500_000, n = 500_000, runs = 500 }, + }; + + /// + /// Run pre-defined test cases and a number of test cases with random parameters. + /// + public static void Test() + { + foreach (TestCase tc in testCases) + Test1Case(tc); + + TestCase t; + Random rnd = new Random(); + for (int i = 0; i < 20; ++i) + { + t.N = rnd.Next(100 + 1); // 0 <= N <= 100 + t.K = rnd.Next(t.N + 1); // 0 <= K <= N + t.n = rnd.Next(t.N + 1); // 0 <= n <= N + t.runs = 10_000; + Test1Case(t); + } + } + + /// + /// Run a test case. + /// + /// Test case. + private static void Test1Case(TestCase tc) + { + // Results. For each possible outcome, the number of its appearances in the generated statistical sample. + int[] r = new int[tc.n + 1]; + + // Generate the sample and measure execution time. + System.Diagnostics.Stopwatch stopwatch = System.Diagnostics.Stopwatch.StartNew(); + for (int i = 0; i < tc.runs; ++i) + { + ++r[Generate(tc.N, tc.K, tc.n)]; + } + stopwatch.Stop(); + long time = stopwatch.ElapsedMilliseconds; + + // True mean. + float mean = (float)tc.n * tc.K / tc.N; + // Sum of observations. + long sum = 0; + // Sum of squares of variance about the known mean. + float sum2 = 0; + for (int k = 0; k <= tc.n; ++k) + { + // Number of observations of outcome k. + int n = r[k]; + sum += (long)n * k; + sum2 += n * (k - mean) * (k - mean); + } + + float sampleMean = (float)sum / tc.runs; + float meanDelta = sampleMean - mean; + // Sample variance about the known mean. It is unbiased, because true mean is used. + float sampleVariance = sum2 / tc.runs; + float variance = sampleMean * (tc.N - tc.K) / tc.N * (tc.N - tc.n) / (tc.N - 1); + float varianceDelta = sampleVariance - variance; + // Are test results good enough? Relative errors are calculated. + bool good = Math.Abs(meanDelta) / (mean == 0 ? 1f : mean) < 0.05 && + Math.Abs(varianceDelta) / (variance == 0 ? 1f : variance) < 0.05; + // Call either Log.Message or Log.Warning, depending on test case results. Lines with "bad" results will stand out visually. + (good ? new Action(Log.Message) : new Action(Log.Warning)) (string.Format( + "{0:#,0}, {1:#,0}, {2:#,0} × {3:#,0}: {4:#,0} ms {5:#,0} ns per, mean {6} Δ {7}, variance {8} Δ {9}", + tc.N, tc.K, tc.n, tc.runs, time, time * 1_000_000 / tc.runs, sampleMean, meanDelta, sampleVariance, varianceDelta)); + } +#endif + } + [StaticConstructorOnStartup] public static class HarmonyPatches { @@ -21,114 +190,232 @@ static HarmonyPatches() nameof(CompFoodPoisonable.PostSplitOff)), postfix: new HarmonyMethod(patchType, nameof(SplitFoodPoisonablePostfix))); - // patch to record poison percent before merging stacks - harmony.Patch(AccessTools.Method( - typeof(CompFoodPoisonable), - nameof(CompFoodPoisonable.PreAbsorbStack)), - prefix: new HarmonyMethod(patchType, nameof(AbsorbStackPoisonablePrefix))); - - // patch to better distribute poison percent when merging stacks + // patch with prefix to record poison data before merging stacks + // patch with postfix to better distribute poison percent when merging stacks harmony.Patch(AccessTools.Method( typeof(CompFoodPoisonable), nameof(CompFoodPoisonable.PreAbsorbStack)), + prefix: new HarmonyMethod(patchType, nameof(AbsorbStackPoisonablePrefix)), postfix: new HarmonyMethod(patchType, nameof(AbsorbStackPoisonablePostfix))); + + #if DEBUG + Log.Error("I hereby summon thee, Debug log window!"); + Hypergeometric.Test(); + #endif } + /// + /// Postfix for CompFoodPoisonable.PostSplitOff() method.
+ /// The method is used to adjust poison data after splitting a stack.
+ /// The postfix corrects the original game behavior, if needed, adjusting data of stacks.
+ /// If invalid values are encountered, the postfix leaves everything as is. + ///
+ /// Component of the base stack. It has the post-split values for count of items and poison-related data. + /// Poison percentage of the base stack. + /// New stack after splitting. Count of items and poison-related data are set already. public static void SplitFoodPoisonablePostfix(CompFoodPoisonable __instance, ref float ___poisonPct, Thing piece) { + #if DEBUG + Log.Message("==================== Splitting ===================="); + #endif + + // An oddity of RimWorld code should be delt here with. + // Sometimes, Thing.SplitOff() method is asked to split a stack, and the new piece is asked to hold the whole stack. + // Notably, this happens after a pawn picks up a stack. + // The method simply points out to the original stack as a new piece, not actually creating a new Thing. + // Then, CompFoodPoisonable.PostSplitOff() is called. It probably shouldn't be, but it is. + // When this happens, new stack (piece parameter) is the same object as original stack (__instance.parent). + // Skip in case. + if (ReferenceEquals(__instance.parent, piece)) + { + #if DEBUG + Log.Message(string.Format("Odd: {0} of {1} pcs at {2}% ({3}) is split to self", + __instance.parent.ThingID, __instance.parent.stackCount, ___poisonPct * 100, __instance.cause)); + #endif + return; + } + + #if DEBUG + Log.Message(string.Format("Preliminary: {0} of {1} pcs at {2}% ({3}) and {4} of {5} pcs at {6}% ({7})", + __instance.parent.ThingID, __instance.parent.stackCount, ___poisonPct * 100, __instance.cause, + piece.ThingID, piece.stackCount, piece.TryGetComp().PoisonPercent * 100, piece.TryGetComp().cause)); + #endif + + // skip if entire base stack is either clean or poisoned + // - base game behavior should be adequate + // At the same time, do sanity check. + if (___poisonPct <= 0 || ___poisonPct >= 1) return; + + // Component of split piece encapsulating information on poisoned items. + CompFoodPoisonable splitComp = piece.TryGetComp(); + // Sanity check. + if (splitComp is null) return; + // verify expectations // - base game behavior is to give split stack same poisonPct as base stack // - if this isn't the case, then base game behavior has changed or another mod is handling this - CompFoodPoisonable compFoodPoisonable = piece.TryGetComp(); - if (___poisonPct != compFoodPoisonable.PoisonPercent) return; + if (___poisonPct != splitComp.PoisonPercent) return; - // skip if entire base stack is poisoned - // - base game behavior should be adequate - if (__instance.PoisonPercent == 1f) return; + // Items left in base stack. + int baseLeft = __instance.parent.stackCount; // skip if either stack is empty // - base game behavior should be adequate - if (piece.stackCount == 0 || __instance.parent.stackCount == 0) return; + // At the same time, do sanity check. + if (baseLeft <= 0 || piece.stackCount <= 0) return; - // allocate poison percents - // - hypergeometric distribution; sample randomly from base stack without replacement - int baseTotal = __instance.parent.stackCount + piece.stackCount; - int basePoison = (int)Math.Round(baseTotal * __instance.PoisonPercent); - int splitPoison = 0; - for (int i = 0; i < piece.stackCount; ++i) - { - if (basePoison == 0) - break; + // Initial number of items in the base stack. + int baseTotal = baseLeft + piece.stackCount; + // Initial number of poisoned items in the base stack. + // Rounding to the nearest whole number is required to correct rounding errors of floating-point calculations. + // Also, in case the mod was added to an existing save, the involved stack may have e.g. 0.9 or 0.4 poisoned items, + // and these numbers are converted to realistic whole numbers. + // This is not totally fair, but can be considered as fixing of problems with poison in food stacks, totally. + int basePoison = (int)Math.Round(baseTotal * ___poisonPct); - if (Rand.Chance((float)basePoison / baseTotal)) - { - --basePoison; - ++splitPoison; - } + #if DEBUG + Log.Message(string.Format("Poisoned: {0}/{1} in total", basePoison, baseTotal)); + #endif - --baseTotal; - } + // Number of poisoned items moved to the split piece. + // Hypergeometric distribution; sample randomly from base stack without replacement. + int splitPoison = Hypergeometric.Generate(N: baseTotal, K: basePoison, n: piece.stackCount); + // Number of poisoned items left in the base stack. + basePoison -= splitPoison; // update split poison props float splitPoisonPct = (float)splitPoison / piece.stackCount; - typeof(CompFoodPoisonable).GetField("poisonPct", BindingFlags.NonPublic | BindingFlags.Instance).SetValue(compFoodPoisonable, splitPoisonPct); - if (splitPoison == 0) compFoodPoisonable.cause = FoodPoisonCause.Unknown; + typeof(CompFoodPoisonable).GetField("poisonPct", BindingFlags.NonPublic | BindingFlags.Instance).SetValue(splitComp, splitPoisonPct); + if (splitPoison == 0) splitComp.cause = FoodPoisonCause.Unknown; // update base poison props - ___poisonPct = (float)basePoison / __instance.parent.stackCount; + ___poisonPct = (float)basePoison / baseLeft; if (basePoison == 0) __instance.cause = FoodPoisonCause.Unknown; + + #if DEBUG + Log.Message(string.Format("Split: {0}/{1} pcs at {2}% ({3}) and {4}/{5} pcs at {6}% ({7})", + basePoison, baseLeft, ___poisonPct * 100, __instance.cause, + splitPoison, piece.stackCount, splitComp.PoisonPercent * 100, splitComp.cause)); + #endif + } + + public struct PoisonData + { + public float poisonPct; + public FoodPoisonCause cause; } - public static void AbsorbStackPoisonablePrefix(CompFoodPoisonable __instance, float ___poisonPct, out float __state) + /// + /// Prefix for CompFoodPoisonable.PreAbsorbStack() method.
+ /// The method is used to adjust poison data before merging stacks.
+ /// The prefix stores poison data of the target stack to the parameter __state. + ///
+ /// Component of the target stack. + /// Poison percentage of the target stack. + /// Poison data of the target stack. + public static void AbsorbStackPoisonablePrefix(CompFoodPoisonable __instance, float ___poisonPct, out PoisonData __state) { - // record original poisonPct - __state = ___poisonPct; + #if DEBUG + Log.Message("==================== Absorbing prefix ===================="); + Log.Message(string.Format("Stack {0} of {1} pcs at {2}% ({3})", + __instance.parent.ThingID, __instance.parent.stackCount, ___poisonPct * 100, __instance.cause)); + #endif + + // record original poisonPct and cause + __state.poisonPct = ___poisonPct; + __state.cause = __instance.cause; } - public static void AbsorbStackPoisonablePostfix(CompFoodPoisonable __instance, ref float ___poisonPct, Thing otherStack, int count, float __state) + /// + /// Postfix for CompFoodPoisonable.PreAbsorbStack() method.
+ /// The method is used to adjust poison data before merging stacks.
+ /// The postfix corrects the original game behavior, if needed, adjusting data of both stacks.
+ /// If invalid values are encountered, the postfix leaves everything as is. + ///
+ /// Component of the target stack. It has updated values for percentage and cause, while the parent stack has the original (pre-absorbtion) count of items. + /// Updated poison percentage of the target stack. + /// Donor stack. It has the original, pre-absorbtion values for count of items and poison-related data. + /// Count of absorbed items. + /// Original, not updated poison data of the target stack, saved by the prefix. + public static void AbsorbStackPoisonablePostfix(CompFoodPoisonable __instance, ref float ___poisonPct, Thing otherStack, int count, PoisonData __state) { - // skip if entire other stack is poisoned + #if DEBUG + Log.Message("==================== Absorbing postfix ===================="); + #endif + + // Component of other stack encapsulating information on poisoned items. + CompFoodPoisonable otherComp = otherStack.TryGetComp(); + // Sanity check. + if (otherComp is null) return; + + #if DEBUG + Log.Message(string.Format("Preliminary: {0} of {1}({2}) pcs at {3}% ({4}) after absorbing {5} pcs from {6} of {7} pcs at {8}% ({9})", + __instance.parent.ThingID, __instance.parent.stackCount, __instance.parent.stackCount + count, ___poisonPct * 100, __instance.cause, + count, otherStack.ThingID, otherStack.stackCount, otherComp.PoisonPercent * 100, otherComp.cause)); + #endif + + // skip if entire other stack is either clean or poisoned // - base game behavior should be adequate - CompFoodPoisonable compFoodPoisonable = otherStack.TryGetComp(); - if (compFoodPoisonable.PoisonPercent == 1f) return; + // At the same time, do sanity check. + float otherPoisonPct = otherComp.PoisonPercent; + if (otherPoisonPct <= 0 || otherPoisonPct >= 1) return; // skip if we're absorbing entire other stack // - base game behavior should be adequate - if (otherStack.stackCount == count) return; + // Also, do sanity check. + int otherTotal = otherStack.stackCount; + if (count >= otherTotal) return; // verify expectations // - base game behavior is to set poisonPct based on weighted average // - if this isn't the case, then base game behavior has changed or another mod is handling this - float weightedAvg = GenMath.WeightedAverage(__state, __instance.parent.stackCount, compFoodPoisonable.PoisonPercent, count); + int targetTotal = __instance.parent.stackCount; + float weightedAvg = GenMath.WeightedAverage(__state.poisonPct, targetTotal, otherPoisonPct, count); if (___poisonPct != weightedAvg) return; + // Performance-wise, sanity checks that should never trigger are performed last. + // Unlike PostSplitOff() case, source and target stacks don't seem to coincide in the normal workflow. So, just a sanity check. + if (ReferenceEquals(__instance.parent, otherStack)) return; + if (targetTotal < 0) return; + // The case (count == 0) is not absolutely insane, but no action is needed anyway. + if (count <= 0) return; + if (__state.poisonPct < 0 || __state.poisonPct > 1) return; + // allocate poison percents - // - hypergeometric distribution; sample randomly from other stack without replacement - int otherTotal = otherStack.stackCount; - int otherPoison = (int)Math.Round(otherTotal * compFoodPoisonable.PoisonPercent); - int targetPoison = (int)Math.Round(__instance.parent.stackCount * __state); - for (int i = 0; i < count; ++i) - { - if (otherPoison == 0 || otherTotal == 0) - break; + // The rationale for values' rounding is commented in SplitFoodPoisonablePostfix(). + int otherPoison = (int)Math.Round(otherTotal * otherPoisonPct); + int targetPoison = (int)Math.Round(targetTotal * __state.poisonPct); - if (Rand.Chance((float)otherPoison / otherTotal)) - { - --otherPoison; - ++targetPoison; - } + #if DEBUG + Log.Message(string.Format("Stack {0}/{1} at {2}% ({3}) is absorbing {4} pcs from stack {5}/{6} at {7}% ({8})", + targetPoison, targetTotal, __state.poisonPct * 100, __state.cause, + count, otherPoison, otherTotal, otherPoisonPct * 100, otherComp.cause)); + #endif - --otherTotal; - } + // Number of poisoned items moved to the target stack. + // Hypergeometric distribution; sample randomly from other stack without replacement. + int splitPoison = Hypergeometric.Generate(N: otherTotal, K: otherPoison, n: count); + // Number of poisoned items left in the other stack. + otherPoison -= splitPoison; + // Number of poisoned items to be in the target stack. + int targetFinalPoison = targetPoison + splitPoison; + + // update target poison props + ___poisonPct = (float)targetFinalPoison / (targetTotal + count); + // Set an appropriate poisoning cause. In case of mixing equal quantities of poisoned items, inherit the cause from other stack, just like original game code does. + __instance.cause = (targetFinalPoison == 0 ? FoodPoisonCause.Unknown : (targetPoison > splitPoison ? __state.cause : otherComp.cause)); // update other poison props - float otherPoisonPct = (float)otherPoison / (otherStack.stackCount - count); - typeof(CompFoodPoisonable).GetField("poisonPct", BindingFlags.NonPublic | BindingFlags.Instance).SetValue(compFoodPoisonable, otherPoisonPct); - if (otherPoison == 0) compFoodPoisonable.cause = FoodPoisonCause.Unknown; + otherPoisonPct = (float)otherPoison / (otherTotal - count); + typeof(CompFoodPoisonable).GetField("poisonPct", BindingFlags.NonPublic | BindingFlags.Instance).SetValue(otherComp, otherPoisonPct); + if (otherPoison == 0) otherComp.cause = FoodPoisonCause.Unknown; - // update target poison props - ___poisonPct = (float)targetPoison / (__instance.parent.stackCount + count); - if (targetPoison == 0) __instance.cause = FoodPoisonCause.Unknown; + #if DEBUG + Log.Message(string.Format("Absorbed: {0}/{1} at {2}% ({3}) after absorbing {4}/{5} and leaving {6}/{7} at {8}% ({9})", + targetFinalPoison, targetTotal + count, ___poisonPct * 100, __instance.cause, + splitPoison, count, + otherPoison, otherTotal - count, otherComp.PoisonPercent * 100, otherComp.cause)); + #endif } } } diff --git a/Source/PoisonedFoodStackFix/PoisonedFoodStackFix.csproj b/Source/PoisonedFoodStackFix/PoisonedFoodStackFix.csproj index d6c3e33..56b1ea2 100644 --- a/Source/PoisonedFoodStackFix/PoisonedFoodStackFix.csproj +++ b/Source/PoisonedFoodStackFix/PoisonedFoodStackFix.csproj @@ -23,12 +23,13 @@ 4 - pdbonly + none true ..\..\Assemblies\ TRACE prompt 4 + false @@ -47,18 +48,10 @@ - - ..\..\..\..\RimWorldWin64_Data\Managed\UnityEngine.dll - False - - - ..\..\..\..\RimWorldWin64_Data\Managed\UnityEngine.CoreModule.dll - False - - \ No newline at end of file + diff --git a/Source/PoisonedFoodStackFix/Properties/AssemblyInfo.cs b/Source/PoisonedFoodStackFix/Properties/AssemblyInfo.cs index 2046bc7..7193b1e 100644 --- a/Source/PoisonedFoodStackFix/Properties/AssemblyInfo.cs +++ b/Source/PoisonedFoodStackFix/Properties/AssemblyInfo.cs @@ -1,5 +1,4 @@ using System.Reflection; -using System.Runtime.CompilerServices; using System.Runtime.InteropServices; // General Information about an assembly is controlled through the following @@ -10,7 +9,7 @@ [assembly: AssemblyConfiguration("")] [assembly: AssemblyCompany("")] [assembly: AssemblyProduct("FoodPoisonStackingFixBinch")] -[assembly: AssemblyCopyright("Copyright © 2023")] +[assembly: AssemblyCopyright("Copyright © 2023")] [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] @@ -32,5 +31,5 @@ // You can specify all the values or you can default the Build and Revision Numbers // by using the '*' as shown below: // [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] +[assembly: AssemblyVersion("1.1.1.1")] +[assembly: AssemblyFileVersion("1.1.1.1")]