From 296229c2afde885842cd5aaa5cc870036709774b Mon Sep 17 00:00:00 2001 From: Doomster14 <146005125+Doomster14@users.noreply.github.com> Date: Sun, 1 Oct 2023 23:22:44 +0300 Subject: [PATCH 1/2] Create README.md --- README.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 README.md 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 From a09b1ab8b63864c5352f2368f549dffb91bcefb3 Mon Sep 17 00:00:00 2001 From: Doomster14 <146005125+Doomster14@users.noreply.github.com> Date: Tue, 3 Oct 2023 15:30:17 +0300 Subject: [PATCH 2/2] Bug fix. Better poisoning cause tracking. Optimizations. More sanity checks... - Fix: When a stack containing poisoned items was moved/merged, the number of poisoned items could change somewhat. And if it was increased, the food could be tagged as having unknown poisoning cause. - Overall, more accurate tracking of poisoning cause. - Added debug output to the in-game debug log window when built in Debug configuration. - Refactoring: A class for hypergeometric distribution generation is created, with tests performed in Debug configuration. - Performance improvements. - Added more sanity checks, the mod does nothing with stacks having invalid parameters. - Debug symbols are disabled in Release configuration build, so build machine paths are not exposed. - Multiple minor improvements. --- Assemblies/PoisonedFoodStackFix.dll | Bin 6144 -> 6144 bytes Source/PoisonedFoodStackFix/HarmonyPatches.cs | 411 +++++++++++++++--- .../PoisonedFoodStackFix.csproj | 13 +- .../Properties/AssemblyInfo.cs | 7 +- 4 files changed, 355 insertions(+), 76 deletions(-) diff --git a/Assemblies/PoisonedFoodStackFix.dll b/Assemblies/PoisonedFoodStackFix.dll index 0e12b8a710a767d91bf479299a9371489f5d5814..8b9bf94c5f0649cfc715f7cc5db5f070cf71e1b4 100644 GIT binary patch delta 2367 zcmY*bYiv|S6h1R|?rVFy-Pv2VR4NwQO4+t_OI1QZ5djNB3z_BNBk>_-3Bf^%NJ5OjS)u* zUyUs<;hS}HrO5JZXKjoqQtwGomWIhJ+vc0REFB}KEM4Oz8ozPtElZkd-?Zy3*fEf1 zlE_cl1v;W~L%V1oy;_MtW5`A10Rw|^OlvOsNKaexTS1nV)`EzX9?)(%#SE4zqBJ)P z-R(2nU%cradVf6a9nSwQOcPVflYYve50DU7ii{)>QZ%!q(KcUY+2rcM*oq}ADQb~H zUBqfoRx39fo{LyATryIQS&^M+a)llyQY>kQ!)-NLqwS&I+FRh!Q-H+ULB+C)9XIW{ z*^tS0`eH^ccQP8dtvWXct1KxVuZAYM3QXJv+e(byijm=D?QCbe(_r(ENIocxQIsJB zA5jjYkXR%kyP)~l4l`MpQAAimksTK5s6MPyC+L~Z;{R1w92FxfxSO@jZ0%gc_l40W z#k2-WHaF48lBTMD(pv{#9G#j$|FIQ!u_RKTlEgRN?4)^`vK(oeS|Z<;rnJ=!vZTGu zcMR!dU2_}i=F+JzQ*6qybh&jAgi$5eu}}|bW*sw~?JT+}>+SiOy4p8omFBdrxT%3K zs|!aEtLgyJALw5f7;hfR_w;uUs(bN*L$m>!R&Cu(H(Vk-2&!t?x^A^HM)2U2?Y`dJ zPBlk^aI9k%Cl@yQi!~GO8C<9aKZI6igQrRq#ShX_2~k0l%1IejuYN=qc-UL?J3C}p zw1X>Lz_u7AbQZ1_J;1JUi@wr$USkVajIXg?<0G11#=5b#T;oa29AwAf`KNJ(7a~+| zTt~R8bcILg30i}~4s*qPshQt2(?*Ip113sOiE>nM$tVZ&54MR?n$-9^x`qfgqDAOi zji-cK^*Xg#%fR*283VAH*m z)5pYRb*NbE0kI*_pd0E?X-G5IA-qA|+GmR4U?dX#zt3A(C8!#QZo=wPi@`_&aSXr+ zEd<6;L`wnPQaxFzah=9Z8q*pxz**Fz$9psmYdi|H=pE{#Y8t0E5%M$5U!on9!eO)+ z_kekcuccopE_M)O(%4PUAg?|2Je7;Rgkq@k+W?-|%Wg1s$emyM(zgaV#8h6n~Fj?9U9Hhf+7md(mb_;c2S1|_F z3J^|xVM{_V^-!kTS=6Q)1AF(pAC>ZG73!(rfYdkD0=-P&FdC^>k;_|iyZ82X-9ziC zjW*_b26O$ZGea3#o5}a(`iD34?4-WIT{%DBi_zNQfv$XaSFW!MkAGM2zVoEhp6T7& zwPko9Xmy+ImahJ;d}e6kfcqQ{a$-(#D_hg_!{~-Xhu>Lqs^)xq`@UmmNK`WB7?s3K z45U=kr~>ZVw!`r`jv&T8i&%V}%oLM|_XVe;Wre=@-uRyIb8Iu-3P;bd;-hwqvAGoA zBqA){iohPXRD|MUx01)ljz;C!F|(4hiVDM$Y0q}!tsYilwPTa;RCCVn-`-w+$F-vf zw~kxb+E8ZbhdXH?YRrpoDhr2ROvXGiCrL-fg4HD*u>|hzs@%YEzNh=qp~{cW1+SDW zF`E|Q3w|ou9%N!=#m6WAiYV1z4jSbgb}Cpe7Y2{XEkQKCza%{1X`FaEo?&hiu9W@- zthB?ppJdQky3}2ao4Xhnizc2eEt=`{#;nGhOUCETb}LE0()U(O$iy#fJZ1j}muXy3 delta 2283 zcmZuzTWl0n82--Oc6K{mc4n7t3#D#ZprwVfP;Rv#XrZ(~5!$q+bQ9gSD=ynIWw#~e zBHNm3KrvdzNQ~emh6Id;kV**g1&{~4B*FtmeF72<>Vq+g2^XXPoax#{J=6Kl`M>j> z|D5y9nb{6Uh9j>Xly)rs@$Iq0#ccQ3sdhHgVysMFT?H(+N`-k=2P3L78mB>J#+#ot| zfXhsqNt4182$%t?=H%*6OzIu^r#bRK#u<>4KSw{2|9;K@M_xe-_D4kAq?(qXOXY-> z9&wXm25J5(Rw3}lOkj9bHLH;J6%aiyU)WbjH4o;KhN8?w=qR@4xsuMvZN241Bu!|+ z{Gea1ELG=~t4UX~vwWJJNL33uG&$bWY2 z?D^rX%Cu>(l$*>^$;t8(nkY}) zq?&cZ<1XmP*Cld6vVGANYAL!RPg)^&kQws-nNefAA!prq$P8V|bwl1te(2JqAy&UU zYbBilgbx76sNOG8yJaAi*ttDzALv3A*huFh+}eU3H$dka;dQNzYp5Qe_=jQt=%%-Tw<#a!Jpnm}DRgIfnv3@XuL|}UK*1%}Dk@kY*!Ty+;uH*$OhE}d zEhsp_@kNe7!Depp_I;MIgC#uhu%$HkHf&6AW;2Tl9^4l$Q=%*4Nm71{lf>*o89Zp? z{3tPAbW0w1@vi70&Oh1u30Wngae{IfftVW)F}7xuo`HnlNH0^b8$pplCsa^G=)qh< zA1%zr8jg>1Y~;9wV+Y3=;Z!6tJlMr?562Ng1!J^_X*k1~^LPTK_>9^Mh}kXF;9J}f zn(-#S5}(3*xGi?mm*}SW41UED?7&?-36nCEU;vCY2?t2|9Y#^gVt5@@bf&%}^y4~V zW;%YSDvUn~D=~vrp_*eY$2!h$K$!Rz&UA9@MlCT(#0k?#u{!L-1=fH=kcP7ljLhTZ zF1ckN~6jfA= zK?y8BpFq&uA;B0P@#@1b$RU9h7fXsh{EElrGMY3e10+qW)-+8$w>R2WwD`s$8sA9P zFgIxWCZA?(QF%-AX?c*dlx8*i+Vuhkqt5K#lS*uVb|7@=RLDB-TP8=U=(6tkqCxeC zv+vs;+17nP_YnOK5BaS1dP%0aWlc*e^w`Kt4?=6te!p(bTSuQ>NUss<9*u5JncGvb zzEFLlH=Yia4=rChH`+1~kM%{7FP(gmiSXnMj*E<307J z+0!x*>rU1uhN3OzV5&R*@4l5zr`fe5+8pnV$I|~%a6wgnPZ!ot{HH*xLyz4KjgD7-rO)JFvk`*N(t}4Vud$ati1se*!t3#)MvS>5bc*?Hu|T`n0K@7G)VM=fMC>9| hj~@@@<%-TsT`)OhthA7n&?w{3s&PH|3mc2L{sxx~fWZI& 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")]