I actually wrote some code to do this. The gist of it is using statistics to correct unlucky streaks. The way you can do this is to keep track of how many times the event has occurred and use that to bias the number generated by the PRNG.
Firstly, how do we keep track of the percentage of events? The naive way of doing this would be to keep all numbers ever generated in memory and average them out: which would work but is horribly inefficient. After a little thinking I came up with the following (which is basically a cumulative moving average).
Take the following PRNG samples (where we proc if the sample is >= 0.5):
Values: 0.1, 0.5, 0.9, 0.4, 0.8
Events: 0 , 1 , 1 , 0 , 1
Percentage: 60%
Notice that each value contributes to 1/5 of the final result. Let's look at it another way:
Values: 0.1, 0.5
Events: 0 , 1
Notice that the 0
contributes to 50% of the value and the 1
contributes 50% of the value. Taken slightly further:
Values: [0.1, 0.5], 0.9
Events: [0 , 1 ], 1
Now the first values contribute 66% of the value and the last 33%. We can basically distil this down to the following process:
result = // 0 or 1 depending on the result of the event that was just generated
new_samples = samples + 1
average = (average * samples / new_samples) + (result * 1 / new_samples)
// Essentially:
average = (average * samples / new_samples) + (result / new_samples)
// You might want to limit this to, say, 100.
// Leaving it to carry on increasing can lead to unfairness
// if the game draws on forever.
samples = new_samples
Now we need to bias the result of the value sampled from the PRNG, because we are going for a percentage chance here things are a lot easier (versus, say, random amounts of damage in a RTS). This is going to be hard to explain because it 'just occurred to me'. If the average is lower it means that we need to increase the chance of the event occurring and visa-versa. So some examples
average = 0.1
desired = 0.5
corrected_chance = 83%
average = 0.2
desired = 0.5
corrected_chance = 71%
average = 0.5
desired = 0.5
corrected_change = 50%
Now what 'occurred to me' is that in the first example 83% was just "0.5 out of 0.6" (in other words "0.5 out of 0.5 plus 0.1"). In random event terms that means either:
procced = (sample * 0.6) > 0.1
// or
procced = (sample * 0.6) <= 0.5
So in order to generate an event you would basically use the following code:
total = average + desired
sample = rng_sample() * total // where the RNG provides a value between 0 and 1
procced = sample <= desired
And therefore you get the code that I put in the gist. I am pretty sure this all can be used in the random damage case scenario, but I haven't taken the time to figure that out.
Disclaimer: This is all home-grown statistics, I have no education in the field. My unit tests do pass though.