This is actually fairly simple to implement in AnyDice:
function: highest N:n of A:s and B:s {
result: {1..N} @ [sort {A, B}]
}
function: lowest N:n of A:s and B:s {
LEN: #A + #B
result: {LEN-N+1 .. LEN} @ [sort {A, B}]
}
These functions assume that you haven't changed the sorting order, which is highest-to-lowest by default. If you reverse it, the "highest" function will start returning the lowest rolls, and vice versa.
You can also easily modify these functions to handle more than two types of dice, as in:
function: highest N:n of A:s and B:s and C:s {
result: {1..N} @ [sort {A, B, C}]
}
function: lowest N:n of A:s and B:s and C:s {
LEN: #A + #B + #C
result: {LEN-N+1 .. LEN} @ [sort {A, B, C}]
}
Now for the bad news: these functions can get very slow for large dice pools, because they enumerate all the possible rolls by brute force. The built-in "highest/lowest N of DICE" functions are optimized using clever math to run very quickly even for large numbers of dice, but these aren't. If you try to compute something like [highest 2 of 10d6 and 10d8]
using these functions, you'll almost certainly get a time-out error.
Also, it's worth noting that, just like the built-ins, these functions return a single biased die describing the sum of the highest / lowest N values. Thus, for example, [highest 1 of [highest 2 of 1d6 and 1d8]]
is not the same as [highest 1 of 1d6 and 1d8]
! As far as I know, actually combining e.g. a d6 and a d8 into a single dice pool in AnyDice is simply not possible; you can have biased dice representing any probability distribution you want, and you can have pools of several identical dice, but you can't have different types of dice in the same pool.
On the plus side, unlike the built-ins, these functions work "as expected" also for sequence inputs: for example, [highest 2 of {1,2} and {3,4}]
returns 7 = 3+4 as expected, not 10 = 1+2+3+4 like the corresponding built-in [highest 2 of {1,2,3,4}]
would(!). (This happens because the built-ins are defined to take a die as their input, not a sequence, and so AnyDice will auto-convert a sequence to a die by summing its values before the function even sees it. I kind of consider that a bug in AnyDice, even if it's technically working exactly as documented.)
Overall this seems to benefit the first player, but it depends how you measure it.
I thought I would model this programatically as it will be a bit more flexible than using AnyDice. I've written a script which carries out the process a large number of times and averages the values for each player.
The main difficulty here is how you actually interpret the data: what makes one array of ability scores better than another one? There are multiple ways to judge this. I'll include some values for different methods.
Total of all ability scores
Player 1 has mean ability score value 72.95522.
Player 2 has mean ability score value 73.43975.
Player 3 has mean ability score value 73.64131.
Player 4 has mean ability score value 73.72761.
Verdict: Being later in the order is better
Total of all ability scores except the lowest one
Player 1 has mean ability score value 66.49585.
Player 2 has mean ability score value 65.71347.
Player 3 has mean ability score value 65.10333.
Player 4 has mean ability score value 64.57315.
Verdict: Being earlier in the order is better
Total points buy cost of all ability scores
(I've used invented points buy scores for numbers outside the normal allowed range: 18 = 19, 17 = 15, 16 = 12, 6-7 = -1, 4-5 = -2, 3 = -1)
Player 1 has mean ability score value 33.43838.
Player 2 has mean ability score value 31.80692.
Player 3 has mean ability score value 31.00389.
Player 4 has mean ability score value 30.64647.
Verdict: Being earlier in the order is better
Total points buy cost of all ability scores except the lowest one
Player 1 has mean ability score value 34.33727.
Player 2 has mean ability score value 31.88918.
Player 3 has mean ability score value 30.39722.
Player 4 has mean ability score value 29.45149.
Verdict: Being earlier in the order is much better
I've included my horrible amateur code here so you can try it out.
#!/usr/bin/perl
use strict;
use warnings;
use List::Util qw(sum);
use Data::Dumper;
use POSIX;
use 5.010;
my $die_size = 6;
my $number_of_dice = 4;
my $number_of_players = 4;
my $number_of_runs = 10000;
sub get_single_ability_score {
my @rolls;
for (1..$number_of_dice) {
my $roll = 1 + int rand($die_size);
push @rolls, $roll;
}
@rolls = sort {$a <=> $b} @rolls;
for (1..$number_of_dice - 3) {
shift @rolls;
}
my $ability_score = sum(@rolls);
return $ability_score;
}
sub get_total_values {
my @group_ability_scores;
for (1..$number_of_players * 6) {
push @group_ability_scores, get_single_ability_score();
}
@group_ability_scores = sort { $b <=> $a } @group_ability_scores;
my @player_order = (1..$number_of_players);
my @reverse_player_order = sort { $b <=> $a } @player_order;
@player_order = (@player_order, @reverse_player_order, @player_order, @reverse_player_order, @player_order, @reverse_player_order);
my @player_ability_scores;
foreach my $player (@player_order) {
my @ability_scores;
my $chosen_ability_score = shift @group_ability_scores;
push @ability_scores, $chosen_ability_score;
push @{ $player_ability_scores[$player-1] }, @ability_scores;
}
my @total_values;
foreach my $player (1..$number_of_players) {
my @ability_scores = sort { $a <=> $b } @{ $player_ability_scores[$player-1] };
my $total_value = 0;
shift @ability_scores; # One dump stat is fine so discard the lowest ability score
foreach my $ability_score (@ability_scores) {
#$total_value += $ability_score; # Uses the score as the value
#$total_value += floor(($ability_score - 10) / 2); # Uses the modifier as the value
given ($ability_score) {
when ($_ == 18) {$total_value += 19}
when ($_ == 17) {$total_value += 15}
when ($_ == 16) {$total_value += 12}
when ($_ == 15) {$total_value += 9}
when ($_ == 14) {$total_value += 7}
when ($_ == 13) {$total_value += 5}
when ($_ == 12) {$total_value += 4}
when ($_ == 11) {$total_value += 3}
when ($_ == 10) {$total_value += 2}
when ($_ == 9) {$total_value += 1}
when ($_ == 8) {$total_value += 0}
when ($_ == 7) {$total_value += -1}
when ($_ == 6) {$total_value += -2}
when ($_ == 5) {$total_value += -3}
when ($_ == 4) {$total_value += -4}
when ($_ == 3) {$total_value += -5}
}
}
push @total_values, $total_value;
}
return @total_values;
}
my @all_values;
for (1..$number_of_runs) {
my @total_values = get_total_values();
foreach my $player (1..$number_of_players) {
push @{ $all_values[$player-1] }, $total_values[$player-1];
}
}
for my $player (1..$number_of_players) {
my $total_value;
foreach my $value (@{ $all_values[$player-1] }) {
$total_value += $value;
}
my $mean_value = $total_value / $number_of_runs;
print "Player $player has mean ability score value $mean_value.\n";
}
Try it online!
Best Answer
Doppelgreener's answer is good if the player must always reroll their lowest die, no matter what they originally rolled. However, if using the ability is optional, the player will most likely choose not to use it if they roll, say, two twelves on their first 2d12 roll.
In general, it's hard to model such optional decision-making processes mathematically, since the rationally optimal decision may depend on what the player's specific goal is (not to mention that players are human, and thus often don't act rationally!). However, in this case, a fairly reasonable class of decision-making rules to consider are those where the player rerolls the lowest die only when it's less than some fixed threshold. In fact, if the player's goal is simply to maximize the expected average result of their roll, their optimal strategy is to reroll a die only when the original value of that die is less than the expected average of the reroll (which, for a d12, is (1+12)/2 = 6.5).
Here's a basic AnyDice script to model that decision-making strategy:
Note that the function in the code above is generic enough to allow arbitrary initial dice pool sizes (although only the lowest die is ever rerolled) and thresholds, and even provides the option for rerolling with a different die than the original pool had, should that be desired.
Looking at the output of the script, we can see that this "reroll lowest if less than 7" strategy significantly outperforms both "always reroll" and "never reroll":
Of course, we could also consider thresholds other than 7 (≈ 6.5). However, the summary statistics do reveal that, at least as far as the expected average outcome is concerned, 7 is indeed the optimal threshold for rerolling a d12.
All that said, other decision-making rules can still do even better in specific circumstances. For example, if the player is trying to roll to meet or exceed a particular target number, the natural and likely optimal rule is simply to reroll if the sum of their original roll is less than the target, and let the original roll stand otherwise.
Of course, we can model that in AnyDice as well:
In this case, we unfortunately don't get such nice graphs out, since each comparison against the target number just outputs 0 if the roll fails and 1 if it succeeds. Still, looking at the "transposed" view (which I've linked directly to above), we can see that allowing the reroll is slightly better than granting the player +3 to their roll. For example, an unmodified 2d12 roll has a 61.81% chance of meeting a target of 12, whereas 2d12 with an optional reroll has a 64.53% chance of meeting a target of 15, and a 56.71% chance of meeting a target of 16.