From 68a495cb14604fb32c452df89655bf26983cd224 Mon Sep 17 00:00:00 2001 From: Aleksei Zakharov Date: Tue, 12 Mar 2024 20:15:09 +0200 Subject: [PATCH] Better histogram calculations, minor improvements --- README.md | 90 ++++++++++++++++++++++++----------------- dice_roller/__init__.py | 3 +- dice_roller/compare.py | 42 +++++++++++++++---- dice_roller/core.py | 30 ++++++++++++-- dice_roller/explode.py | 27 +++++-------- dice_roller/misc.py | 27 +++++-------- dice_roller/random.py | 20 ++++++++- dice_roller/reroll.py | 25 ++++-------- pyproject.toml | 2 +- 9 files changed, 164 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index 53ba3dc..56300df 100644 --- a/README.md +++ b/README.md @@ -62,57 +62,71 @@ Rolling (d20 + 4) = 7 ## Highlights ```python -from dice_roller import s, d, rng, x, r, kh, kl, dl, dh +from dice_roller import s, d, rng def roll_info(roll: BaseDice): print(f"For dice '{roll}' min is {roll.min()} and max is {roll.max()}") -d20 = d(20) # creating dice with possible outcomes 1-20 -roll_info(d20) # For dice 'd20' min is 1 and max is 20 -d20.roll() # Get one roll result -d20.generate(10) # Generates 10 rolls as numpy array +d20 = d(20) # creating dice with possible outcomes 1-20 +roll_info(d20) # For dice 'd20' min is 1 and max is 20 +d20.roll() # Get one roll result +d20.generate(10) # Generates 10 rolls as numpy array +roll_info(d20 + 5) # For dice '(d20 + 5)' min is 6 and max is 25 -modifier = s(5) # Scalar, `dice_roller` converts integers to Scalar automatically, if you use suggested mathematical api. -roll_info(modifier) # For dice '5' min is 5 and max is 5 -roll_info(d20 + modifier) # For dice '(d20 + 5)' min is 6 and max is 25 - -fudge_dice = rng(-1, 2) # Creating custom range dice -roll_info(fudge_dice) # For dice 'rng(-1,2)' min is -1 and max is 1 -fudge_dice.generate(5*3).reshape((5, 3)) # rolling a pool of dices and reshape outcome with numpy tools +fudge_dice = rng(-1, 2) # Creating custom range dice +roll_info(fudge_dice) # For dice 'rng(-1,2)' min is -1 and max is 1 +fudge_dice.generate(5*3).reshape((5, 3)) # rolling a pool of dices and reshape outcome with numpy tools # array([[-1, -1, 0], # [-1, 1, -1], # [-1, 1, 0], # [ 0, 0, 0], # [-1, -1, 0]]) -attack_roll = kh(2@d20) + d(4) + 3 # Roll to hit with advantage, use bless (+d4) and add +3 modifier -roll_info(attack_roll) # For dice '(2d20kh + d4 + 3)' min is 5 and max is 27 +advantage_roll = (2@d20).kh() # Keep Highest of 2 dices +disadvantage_roll = (2@d20).kl() # Keep Lowest of 2 dices + +attack_roll = (2@d20).kh() + d(4) + 3 # Roll to hit with advantage, use bless (+d4) and add +3 modifier +roll_info(attack_roll) # For dice '(2d20kh + d4 + 3)' min is 5 and max is 27 attack_results = attack_roll.generate(10_000) # Generates 10000 rolls hit_ac = attack_results[attack_results >= 16] # Checking hits with numpy masks -damage_roll = (d(6).x == 6) # Roll d6 (explode on 6). Explode max 100 times (default) -roll_info(damage_roll) # For dice 'd6x6' min is 1 and max is 600 -explode_on_six = (x() == 6) # Functional API for explode -roll_info(explode_on_six(d(6))) # For dice 'd6x6' min is 1 and max is 600 - -d20_with_luck = (d20.r == 1) # Roll d20 and reroll ones. Reroll once (default) -roll_info(d20_with_luck) # For dice 'd20r1' min is 1 and max is 20 -reroll_ones = (r() == 1) # Functional API for explode -d20_with_luck_plus_4 = reroll_ones(d20) + 4 # Roll d20 and reroll ones (max 1 reroll), add 4 -kl(2@d20_with_luck_plus_4).roll() # roll 2 rolls, keeping one lowest result - -# More examples -(4@fudge_dice) # roll 4 fudge dices, add results together -(d20 - 4) >= 1 # roll d20-4, ensure result greater or equal to 1 -d20 * 2 + d(2) - 1 # roll d20, multiply by 2, add d2, sub 1 -dh(10@d20, drop=5) # roll 10 d20, drop 5 highest and return sum of rest -kh(5@d20, keep=d(2)) # roll 5 d20, keep d2 (new reroll value each time) highest and return their sum -d(6).r == rng(1, 3) # roll d6, rerolls on 1 or 2 (new reroll value each time), 1 reroll max (default) -d(20).reroll(reroll_limit=10) == 1 # roll d20, rerolls on 1, max 10 rerolls -d(6).x >= 5 # roll d6, explodes on 5 and 6. Maximum 100 explodes (default) -d(6).explode(explode_depth=2) > 4 # roll d6, explodes on 5 and 6. Maximum 2 explodes -(10@d(10)) * (10@d(10)) # roll 2 sets of 10d10 and multiply results -(4 @ d(4)) @ d(10) # roll 4d4 of d10 dices +damage_roll = (d(6).x == 6) # Roll d6 (explode on 6). Explode max 100 times (default) +roll_info(damage_roll) # For dice 'd6x6' min is 1 and max is 600 + +d20_luck = (d20.r == 1) # Roll d20 and reroll ones. Reroll once (default) +roll_info(d20_luck) # For dice 'd20r1' min is 1 and max is 20 + +debuff_roll = (d20 - 4) >= 1 # Limiting your roll outcomes +roll_info(debuff_roll) # For dice '(d20 - 4)>=1' min is 1 and max is 16 + +# Let's use some functional api +from dice_roller import x, r, kh, kl, dl, dh, lim + +kh(2@d(20)) # Keep Highest of 2 dices +kl(2@d(20)) # Keep Lowest of 2 dices +dh(10@d20, drop=5) # roll 10 d20, drop 5 highest and return sum of rest +kh(5@d20, keep=d(2)) # roll 5 d20, keep d2 (new reroll value each time) highest and return their sum + +explode_on_six = (x() == 6) # Explode on 6, max 100 times (default) +explode_on_six = (x(explode_depth=10) == 6) # Explode on 6, max 10 times +roll_info(explode_on_six(d(6))) # For dice 'd6x6' min is 1 and max is 600 + +reroll_ones = (r() == 1) # reroll ones, 1 reroll max (default) +reroll_ones = (r(reroll_limit=10) == 1) # reroll ones, 100 reroll max +d20_luck = reroll_ones(d20) # roll d20 and +kl((2@d20_luck)).roll() # roll 2 d20 rolls, keeping one lowest + +# Even more examples +(4@fudge_dice) # roll 4 fudge dices, add results together +(d20 - d(4)) >= 1 # roll d20-d4, ensure result greater or equal to 1 +d20 * 2 + d(2) - 1 # roll d20, multiply by 2, add d2, sub 1 + +d(6).r == d(2) # roll d6, rerolls on 1 or 2 (new reroll value each time), 1 reroll max (default) +d(20).reroll(reroll_limit=10) == 1 # roll d20, rerolls on 1, max 10 rerolls +d(6).x >= rng(5, 7) # roll d6, explodes on 5 and 6. Maximum 100 explodes (default) +d(6).explode(explode_depth=2) > 4 # roll d6, explodes on 5 and 6. Maximum 2 explodes +(10@d(10)) * (10@d(10)) # roll 2 sets of 10d10 and multiply results +(4@d(4)) @ d(10) # roll 4d4 of d10 dices # roll d6 of (roll d4 of d20 dice, keep 1 highest) and drop d4 lowest. Ensure (d4 explodes on 4) <= result <= (d100 reroll <= 50). (dl(d(6) @ kl(d(4) @ d(20)), drop=d(4)) >= (d(4).x == 4)) <= (d(100).r <= 50) @@ -684,7 +698,7 @@ from dice_roller import Reroll (Reroll() == 6)(some_dice).roll() # is okay ``` -By default, roll can be rerolled once, but you can override this behavior: +**By default, roll can be rerolled once**, but you can override this behavior: ```python from dice_roller import r, d # r is alias for Reroll diff --git a/dice_roller/__init__.py b/dice_roller/__init__.py index ff2b948..fd0871e 100644 --- a/dice_roller/__init__.py +++ b/dice_roller/__init__.py @@ -1,6 +1,6 @@ from . import random from .callback import WithGenerateCallback, WithRollCallback -from .compare import Ge, Gt, Le, Lt +from .compare import Ge, Gt, Le, Lt, Limit from .core import ( BaseDice, Dice, @@ -27,3 +27,4 @@ x = Explode r = Reroll rng = RangeDice +lim = Limit diff --git a/dice_roller/compare.py b/dice_roller/compare.py index 36637d1..c0ecf85 100644 --- a/dice_roller/compare.py +++ b/dice_roller/compare.py @@ -1,5 +1,6 @@ from dataclasses import dataclass -from typing import Callable, Protocol +from functools import partial +from typing import Protocol import numpy as np from dyce import H @@ -7,6 +8,7 @@ from numpy.typing import ArrayLike from .core import BaseDice +from .misc import DiceModifier, _wrap_scalar @dataclass(slots=True) @@ -20,14 +22,18 @@ def _with_cap(roll_values: ArrayLike, cmp_values: ArrayLike) -> tuple[ArrayLike, @staticmethod def _compare_histogram_outcome(dice: int, compare: int) -> bool: ... + @staticmethod + def _modify_input_histogram(dice: H, compare: H) -> tuple[H, H]: + return dice, compare + def histogram(self) -> H: @expandable def cmp(dice: HResult, compare: HResult): - if self._compare_histogram_outcome(dice.outcome, compare.outcome): # type: ignore + if not self._compare_histogram_outcome(dice.outcome, compare.outcome): # type: ignore return compare.outcome return dice.outcome - return cmp(self.dice.histogram(), self.compare.histogram()) + return cmp(*self._modify_input_histogram(self.dice.histogram(), self.compare.histogram())) def generate(self, items: int) -> ArrayLike: result_rolls = self.dice.generate(items) @@ -48,7 +54,11 @@ def min(self) -> int: @staticmethod def _compare_histogram_outcome(dice: int, compare: int) -> bool: - return dice > compare + return dice < compare + + @staticmethod + def _modify_input_histogram(dice: H, compare: H) -> tuple[H, H]: + return dice, compare - 1 # type: ignore @staticmethod def _with_cap(roll_values: ArrayLike, cmp_values: ArrayLike) -> tuple[ArrayLike, ArrayLike]: @@ -68,7 +78,7 @@ def min(self) -> int: @staticmethod def _compare_histogram_outcome(dice: int, compare: int) -> bool: - return dice >= compare + return dice <= compare @staticmethod def _with_cap(roll_values: ArrayLike, cmp_values: ArrayLike) -> tuple[ArrayLike, ArrayLike]: @@ -88,7 +98,11 @@ def min(self) -> int: @staticmethod def _compare_histogram_outcome(dice: int, compare: int) -> bool: - return dice < compare + return dice > compare + + @staticmethod + def _modify_input_histogram(dice: H, compare: H) -> tuple[H, H]: + return dice, compare + 1 # type: ignore @staticmethod def _with_cap(roll_values: ArrayLike, cmp_values: ArrayLike) -> tuple[ArrayLike, ArrayLike]: @@ -108,8 +122,22 @@ def min(self) -> int: @staticmethod def _compare_histogram_outcome(dice: int, compare: int) -> bool: - return dice <= compare + return dice >= compare @staticmethod def _with_cap(roll_values: ArrayLike, cmp_values: ArrayLike) -> tuple[ArrayLike, ArrayLike]: return np.maximum(roll_values, cmp_values) # type: ignore + + +class Limit: + def __gt__(self, value: BaseDice | int) -> DiceModifier: + return partial(Gt, compare=_wrap_scalar(value)) + + def __ge__(self, value: BaseDice | int) -> DiceModifier: + return partial(Ge, compare=_wrap_scalar(value)) + + def __lt__(self, value: BaseDice | int) -> DiceModifier: + return partial(Lt, compare=_wrap_scalar(value)) + + def __le__(self, value: BaseDice | int) -> DiceModifier: + return partial(Le, compare=_wrap_scalar(value)) diff --git a/dice_roller/core.py b/dice_roller/core.py index 4eba8cc..30be48a 100644 --- a/dice_roller/core.py +++ b/dice_roller/core.py @@ -28,7 +28,32 @@ def __str__(self) -> str: def histogram(self) -> H: ... - # Interface end + # Base roll + + def roll(self) -> int: + return np.sum(self.generate(1)) + + # Modifiers + + def kh(self, keep: BaseDice | int = 1) -> BaseDice: + from .transformations import KeepHighest + + return KeepHighest(self, keep=keep) # type: ignore + + def kl(self, keep: BaseDice | int = 1) -> BaseDice: + from .transformations import KeepLowest + + return KeepLowest(self, keep=keep) # type: ignore + + def dh(self, drop: BaseDice | int = 1) -> BaseDice: + from .transformations import DropHighest + + return DropHighest(self, drop=drop) # type: ignore + + def dl(self, drop: BaseDice | int = 1) -> BaseDice: + from .transformations import DropLowest + + return DropLowest(self, drop=drop) # type: ignore @property def r(self): @@ -48,8 +73,7 @@ def explode(self, explode_depth: int = 100): return _ExplodeFactory(dice=self, explode_depth=explode_depth) - def roll(self) -> int: - return np.sum(self.generate(1)) + # Magic def __matmul__(self, other): if isinstance(other, int): diff --git a/dice_roller/explode.py b/dice_roller/explode.py index 2133d79..485d38f 100644 --- a/dice_roller/explode.py +++ b/dice_roller/explode.py @@ -1,15 +1,14 @@ from dataclasses import dataclass, field from functools import partial -from typing import Callable, Protocol +from typing import Protocol import numpy as np from dyce import H -from dyce.evaluation import expandable, HResult +from dyce.evaluation import HResult, expandable from numpy.typing import ArrayLike -from .core import BaseDice, Scalar - -ExplodeDiceModifier = Callable[[BaseDice], BaseDice] +from .core import BaseDice +from .misc import DiceModifier, _wrap_scalar @dataclass(slots=True) @@ -133,31 +132,23 @@ def _calculate_explode_mask(roll_values: ArrayLike, cmp_values: ArrayLike) -> Ar return roll_values <= cmp_values # type: ignore -def _wrap_scalar(value: BaseDice | int) -> BaseDice: - if isinstance(value, int): - value = Scalar(value) - if not isinstance(value, BaseDice): - raise TypeError("Explode only support other dices or integers") - return value - - class Explode: def __init__(self, explode_depth: int = 100) -> None: self.explode_depth = explode_depth - def __eq__(self, value: BaseDice | int) -> ExplodeDiceModifier: # type: ignore + def __eq__(self, value: BaseDice | int) -> DiceModifier: # type: ignore return partial(ExplodeEq, compare=_wrap_scalar(value), explode_depth=self.explode_depth) - def __gt__(self, value: BaseDice | int) -> ExplodeDiceModifier: + def __gt__(self, value: BaseDice | int) -> DiceModifier: return partial(ExplodeIfGreater, compare=_wrap_scalar(value), explode_depth=self.explode_depth) - def __ge__(self, value: BaseDice | int) -> ExplodeDiceModifier: + def __ge__(self, value: BaseDice | int) -> DiceModifier: return partial(ExplodeIfGreaterOrEq, compare=_wrap_scalar(value), explode_depth=self.explode_depth) - def __lt__(self, value: BaseDice | int) -> ExplodeDiceModifier: + def __lt__(self, value: BaseDice | int) -> DiceModifier: return partial(ExplodeIfLess, compare=_wrap_scalar(value), explode_depth=self.explode_depth) - def __le__(self, value: BaseDice | int) -> ExplodeDiceModifier: + def __le__(self, value: BaseDice | int) -> DiceModifier: return partial(ExplodeIfLessOrEq, compare=_wrap_scalar(value), explode_depth=self.explode_depth) diff --git a/dice_roller/misc.py b/dice_roller/misc.py index b23edc8..c63daa1 100644 --- a/dice_roller/misc.py +++ b/dice_roller/misc.py @@ -1,18 +1,13 @@ -class SingletonMeta(type): - """ - The Singleton class can be implemented in different ways in Python. Some - possible methods include: base class, decorator, metaclass. We will use the - metaclass because it is best suited for this purpose. - """ +from .core import BaseDice, Scalar +from typing import Callable - _instances = {} # type: ignore - def __call__(cls, *args, **kwargs): - """ - Possible changes to the value of the `__init__` argument do not affect - the returned instance. - """ - if cls not in cls._instances: - instance = super().__call__(*args, **kwargs) - cls._instances[cls] = instance - return cls._instances[cls] +def _wrap_scalar(value: BaseDice | int) -> BaseDice: + if isinstance(value, int): + value = Scalar(value) + if not isinstance(value, BaseDice): + raise TypeError("Reroll only support other dices or integers") + return value + + +DiceModifier = Callable[[BaseDice], BaseDice] diff --git a/dice_roller/random.py b/dice_roller/random.py index ac69df9..d2cba4e 100644 --- a/dice_roller/random.py +++ b/dice_roller/random.py @@ -1,6 +1,24 @@ import numpy as np -from .misc import SingletonMeta + +class SingletonMeta(type): + """ + The Singleton class can be implemented in different ways in Python. Some + possible methods include: base class, decorator, metaclass. We will use the + metaclass because it is best suited for this purpose. + """ + + _instances = {} # type: ignore + + def __call__(cls, *args, **kwargs): + """ + Possible changes to the value of the `__init__` argument do not affect + the returned instance. + """ + if cls not in cls._instances: + instance = super().__call__(*args, **kwargs) + cls._instances[cls] = instance + return cls._instances[cls] class Rng(metaclass=SingletonMeta): diff --git a/dice_roller/reroll.py b/dice_roller/reroll.py index 7e2c796..44e4f60 100644 --- a/dice_roller/reroll.py +++ b/dice_roller/reroll.py @@ -1,15 +1,14 @@ from dataclasses import dataclass, field from functools import partial -from typing import Callable, Protocol +from typing import Protocol import numpy as np from dyce import H from dyce.evaluation import HResult, expandable from numpy.typing import ArrayLike -from .core import BaseDice, Scalar - -RerollDiceModifier = Callable[[BaseDice], BaseDice] +from .core import BaseDice +from .misc import DiceModifier, _wrap_scalar @dataclass(slots=True) @@ -128,31 +127,23 @@ def _calculate_reroll_mask(roll_values: ArrayLike, cmp_values: ArrayLike) -> Arr return roll_values <= cmp_values # type: ignore -def _wrap_scalar(value: BaseDice | int) -> BaseDice: - if isinstance(value, int): - value = Scalar(value) - if not isinstance(value, BaseDice): - raise TypeError("Reroll only support other dices or integers") - return value - - class Reroll: def __init__(self, reroll_limit: int = 1) -> None: self.reroll_limit = reroll_limit - def __eq__(self, value: BaseDice | int) -> RerollDiceModifier: # type: ignore + def __eq__(self, value: BaseDice | int) -> DiceModifier: # type: ignore return partial(RerollEq, compare=_wrap_scalar(value), reroll_limit=self.reroll_limit) - def __gt__(self, value: BaseDice | int) -> RerollDiceModifier: + def __gt__(self, value: BaseDice | int) -> DiceModifier: return partial(RerollIfGreater, compare=_wrap_scalar(value), reroll_limit=self.reroll_limit) - def __ge__(self, value: BaseDice | int) -> RerollDiceModifier: + def __ge__(self, value: BaseDice | int) -> DiceModifier: return partial(RerollIfGreaterOrEq, compare=_wrap_scalar(value), reroll_limit=self.reroll_limit) - def __lt__(self, value: BaseDice | int) -> RerollDiceModifier: + def __lt__(self, value: BaseDice | int) -> DiceModifier: return partial(RerollIfLess, compare=_wrap_scalar(value), reroll_limit=self.reroll_limit) - def __le__(self, value: BaseDice | int) -> RerollDiceModifier: + def __le__(self, value: BaseDice | int) -> DiceModifier: return partial(RerollIfLessOrEq, compare=_wrap_scalar(value), reroll_limit=self.reroll_limit) diff --git a/pyproject.toml b/pyproject.toml index 48244e8..212d2a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pydiceroll" -version = "0.1.0" +version = "0.2.0" description = "Python tool for rolling a lot of dice" authors = ["Aleksei Zakharov "] license = "MIT"