Compare commits
3 Commits
accdf78293
...
18224b1ea7
Author | SHA1 | Date |
---|---|---|
mat ess | 18224b1ea7 | |
mat ess | f192f1f1f3 | |
mat ess | e985ca230c |
|
@ -5,3 +5,4 @@ __pycache__
|
|||
.*_cache
|
||||
.pre-commit-config.yaml
|
||||
.vscode
|
||||
.coverage
|
||||
|
|
|
@ -26,5 +26,7 @@ total | 1
|
|||
|
||||
## todo
|
||||
|
||||
- [ ] roll with (dis)advantage
|
||||
- [x] roll with (dis)advantage
|
||||
- [ ] interactive rolling mode
|
||||
- [x] print criticals
|
||||
- [ ] use property testing
|
||||
|
|
12
flake.lock
12
flake.lock
|
@ -74,11 +74,11 @@
|
|||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1691579266,
|
||||
"narHash": "sha256-Ue2iaxU5VxwjXX6bWv/ElOl35O4+Rk630jBjMqQDRRs=",
|
||||
"lastModified": 1691931346,
|
||||
"narHash": "sha256-QLK0wLyJEnLU37CTBNnZBY6mNily7w8zeb34XyhSGh0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "a14013769370b021e23200e7199d8cfaeb97098a",
|
||||
"rev": "8f38b58c34bb759ae83a0581120fe2d7fa6bb539",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
@ -133,11 +133,11 @@
|
|||
"nixpkgs-stable": "nixpkgs-stable"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1691397944,
|
||||
"narHash": "sha256-4fa4bX3kPYKpEssgrFRxRCPVXczidArDeSWaUMSzQAU=",
|
||||
"lastModified": 1691747570,
|
||||
"narHash": "sha256-J3fnIwJtHVQ0tK2JMBv4oAmII+1mCdXdpeCxtIsrL2A=",
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"rev": "e5588ddffd4c3578547a86ef40ec9a6fbdae2986",
|
||||
"rev": "c5ac3aa3324bd8aebe8622a3fc92eeb3975d317a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
|
@ -26,8 +26,8 @@
|
|||
pre-commit.settings.hooks = {
|
||||
autoflake.enable = true;
|
||||
black.enable = true;
|
||||
# mypy.enable = true;
|
||||
ruff.enable = true;
|
||||
pyright.enable = true;
|
||||
};
|
||||
packages.default = package;
|
||||
devShells.default = pkgs.mkShell {
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
build:
|
||||
hatch build
|
||||
|
||||
typing:
|
||||
hatch run typing:check
|
||||
|
||||
lint:
|
||||
hatch run lint:check
|
||||
|
||||
fmt:
|
||||
hatch run lint:fmt
|
||||
|
||||
# run all checks
|
||||
test: typing lint
|
||||
hatch run check
|
||||
|
||||
# create hatch envs
|
||||
env:
|
||||
#!/usr/bin/env fish
|
||||
for env in (hatch env show --json | jq 'keys[]' --raw-output)
|
||||
hatch env create $env
|
||||
end
|
||||
|
||||
clean:
|
||||
hatch clean
|
||||
hatch env purge
|
||||
rm -r .{mypy,pytest,ruff}_cache
|
||||
rm -r dist
|
||||
rm .coverage
|
||||
fd __pycache__ --no-ignore --exec rm -r
|
|
@ -30,7 +30,7 @@ Issues = "https://git.mat.services/mat/roll/issues"
|
|||
Source = "https://git.mat.services/mat/roll"
|
||||
|
||||
[project.scripts]
|
||||
roll = "roll.cli:roll"
|
||||
roll = "roll.cli:cli"
|
||||
|
||||
[tool.hatch.version]
|
||||
path = "roll/__about__.py"
|
||||
|
@ -47,7 +47,7 @@ cov-report = [
|
|||
"- coverage combine",
|
||||
"coverage report",
|
||||
]
|
||||
cov = [
|
||||
check = [
|
||||
"test-cov",
|
||||
"cov-report",
|
||||
]
|
||||
|
@ -55,15 +55,18 @@ cov = [
|
|||
[[tool.hatch.envs.all.matrix]]
|
||||
python = ["3.11"]
|
||||
|
||||
[tool.hatch.envs.typing]
|
||||
extra-dependencies = ["mypy"]
|
||||
[tool.hatch.envs.typing.scripts]
|
||||
check = "mypy --install-types --non-interactive {args:roll tests}"
|
||||
|
||||
[tool.hatch.envs.lint]
|
||||
detached = true
|
||||
dependencies = [
|
||||
"black>=23.1.0",
|
||||
"pyright>=1.1.319",
|
||||
"ruff>=0.0.243",
|
||||
]
|
||||
[tool.hatch.envs.lint.scripts]
|
||||
typing = "pyright --project pyproject.toml {args:roll tests}"
|
||||
style = [
|
||||
"ruff {args:.}",
|
||||
"black --check --diff {args:.}",
|
||||
|
@ -73,19 +76,18 @@ fmt = [
|
|||
"ruff --fix {args:.}",
|
||||
"style",
|
||||
]
|
||||
all = [
|
||||
check = [
|
||||
"style",
|
||||
"typing",
|
||||
]
|
||||
|
||||
[tool.black]
|
||||
target-version = ["py311"]
|
||||
line-length = 120
|
||||
skip-string-normalization = true
|
||||
line-length = 100
|
||||
# skip-string-normalization = true
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
line-length = 120
|
||||
line-length = 100
|
||||
select = [
|
||||
"A",
|
||||
"ARG",
|
||||
|
@ -153,8 +155,21 @@ roll = ["roll", "*/roll/roll"]
|
|||
tests = ["tests", "*/roll/tests"]
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
skip_empty = true
|
||||
fail_under = 100
|
||||
exclude_lines = [
|
||||
"no cov",
|
||||
"if __name__ == .__main__.:",
|
||||
"if TYPE_CHECKING:",
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
pretty = true
|
||||
strict = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "tests.*"
|
||||
allow_untyped_defs = true
|
||||
allow_incomplete_defs = true
|
||||
check_untyped_defs = true
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import sys
|
||||
|
||||
if __name__ == "__main__":
|
||||
from roll.cli import roll
|
||||
import sys
|
||||
|
||||
sys.exit(roll())
|
||||
from roll.cli import cli
|
||||
|
||||
sys.exit(cli())
|
||||
|
|
|
@ -1,50 +1,92 @@
|
|||
from typing import Literal
|
||||
|
||||
import click
|
||||
|
||||
from roll.__about__ import __version__
|
||||
from roll.cli.roll_param import ROLL
|
||||
from roll.roll import Roll
|
||||
from roll.throw import Throw
|
||||
|
||||
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
|
||||
|
||||
|
||||
@click.group(
|
||||
context_settings={"help_option_names": ["-h", "--help"]},
|
||||
invoke_without_command=True,
|
||||
)
|
||||
@click.group(context_settings=CONTEXT_SETTINGS, invoke_without_command=True)
|
||||
@click.version_option(version=__version__, prog_name="roll")
|
||||
@click.argument("rolls", nargs=-1, type=ROLL)
|
||||
def roll(rolls: list[Roll]):
|
||||
"""Throw each roll specified in ROLLS and print the results.
|
||||
@click.argument("roll", type=ROLL, default="1d20")
|
||||
def cli(roll: Roll | Literal["advantage"] | Literal["disadvantage"]) -> None:
|
||||
"""throw a ROLL and print the results
|
||||
|
||||
Rolls are specified as
|
||||
ROLL is specified as
|
||||
|
||||
DdS[(+|-)M]
|
||||
|
||||
where D = # of dice, S = sides per die, and M = optional modifier.
|
||||
where D = # of dice, S = sides per die, and M = optional modifier
|
||||
|
||||
Example usage:
|
||||
if no ROLL is provided, a 1d20 is thrown
|
||||
|
||||
instead of a ROLL specifier, you can use a substring of "advantage"
|
||||
or "disadvantage" to throw 2d20 and take the appropriate result
|
||||
|
||||
example usage:
|
||||
|
||||
\b
|
||||
$ roll 2d20+3 3d6-1
|
||||
$ roll 2d20+3
|
||||
rolling 2d20+3:
|
||||
1: | 2
|
||||
2: | 13
|
||||
mod: +3
|
||||
total: 18
|
||||
\b
|
||||
rolling 3d6-1:
|
||||
1: | 3
|
||||
2: | 3
|
||||
3: | 1
|
||||
mod: -1
|
||||
total: 6
|
||||
|
||||
$ roll
|
||||
rolling 1d20:
|
||||
1: | 11
|
||||
total: 11
|
||||
\b
|
||||
$ roll advantage
|
||||
rolling 2d20 with advantage:
|
||||
1: | 13
|
||||
1: | 2
|
||||
total: 13
|
||||
\b
|
||||
$ roll dis
|
||||
rolling 2d20 with disadvantage:
|
||||
1: | 1
|
||||
2: | 19
|
||||
total: 1
|
||||
critical miss!
|
||||
"""
|
||||
for roll in rolls:
|
||||
click.echo()
|
||||
click.echo(f"rolling {roll.to_str()}:")
|
||||
throw = roll.throw()
|
||||
for i, result in enumerate(throw.results):
|
||||
click.echo(f"{i + 1}:\t| {result: >3}")
|
||||
if roll.modifier:
|
||||
mod = roll.modifier_str()
|
||||
click.echo(f"mod:\t {mod: >4}")
|
||||
click.echo(f"total:\t {throw.total: >3}")
|
||||
if isinstance(roll, str):
|
||||
throw = _advantage(roll)
|
||||
else:
|
||||
throw = _throw(roll)
|
||||
click.echo(f"total:\t{throw.total: >5}")
|
||||
if throw.is_critical_hit: # pragma: no cover (random)
|
||||
click.echo("critical hit!")
|
||||
elif throw.is_critical_miss: # pragma: no cover (random)
|
||||
click.echo("critical miss!")
|
||||
|
||||
|
||||
def _advantage(advantage: str) -> Throw:
|
||||
"""print the results of an advantage or disadvantage roll"""
|
||||
click.echo(f"throwing 2d20 with {advantage}:")
|
||||
roll = Roll()
|
||||
fst = roll.throw()
|
||||
snd = roll.throw()
|
||||
click.echo(f"1:\t|{fst.total: >4}")
|
||||
click.echo(f"2:\t|{snd.total: >4}")
|
||||
if advantage == "advantage":
|
||||
throw = fst if fst.total > snd.total else snd
|
||||
else:
|
||||
throw = fst if fst.total < snd.total else snd
|
||||
return throw
|
||||
|
||||
|
||||
def _throw(roll: Roll) -> Throw:
|
||||
"""print the results of a roll"""
|
||||
click.echo(f"throwing {roll.to_str()}:")
|
||||
throw = roll.throw()
|
||||
for i, result in enumerate(throw.results, start=1):
|
||||
click.echo(f"{i}:\t|{result: >4}")
|
||||
if roll.modifier is not None:
|
||||
click.echo(f"mod:\t{roll.modifier_str(): >5}")
|
||||
return throw
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
from typing import Literal
|
||||
|
||||
import click
|
||||
|
||||
from roll.roll import Roll
|
||||
|
@ -6,14 +8,20 @@ from roll.roll import Roll
|
|||
class RollParam(click.ParamType):
|
||||
name = "roll"
|
||||
|
||||
def convert(self, value: str | Roll, param: click.Parameter | None, ctx: click.Context | None) -> Roll:
|
||||
"""Parse a Roll from a command line string."""
|
||||
def convert(
|
||||
self, value: str | Roll, param: click.Parameter | None, ctx: click.Context | None
|
||||
) -> Roll | Literal["advantage"] | Literal["disadvantage"]:
|
||||
"""parse a Roll from a command line string"""
|
||||
if isinstance(value, Roll):
|
||||
return value
|
||||
elif "advantage".startswith(value):
|
||||
return "advantage"
|
||||
elif "disadvantage".startswith(value):
|
||||
return "disadvantage"
|
||||
try:
|
||||
return Roll.from_str(value)
|
||||
except Exception as e:
|
||||
self.fail(f"invalid roll: {value!r}, caused by {e}", param, ctx)
|
||||
self.fail(f"{e}", param, ctx)
|
||||
|
||||
|
||||
ROLL = RollParam()
|
||||
|
|
20
roll/roll.py
20
roll/roll.py
|
@ -12,13 +12,13 @@ ROLL_PATTERN = re.compile(r"(\d+)d(\d+)([+-]\d+)?")
|
|||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Roll:
|
||||
"""A roll of one or more dice"""
|
||||
"""a roll of one or more dice"""
|
||||
|
||||
dice_count: int
|
||||
sides: int
|
||||
dice_count: int = 1
|
||||
sides: int = 20
|
||||
modifier: int | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
def __post_init__(self) -> None:
|
||||
if self.dice_count < 1:
|
||||
msg = "dice must be greater than 0"
|
||||
raise ValueError(msg)
|
||||
|
@ -28,7 +28,7 @@ class Roll:
|
|||
|
||||
@classmethod
|
||||
def from_str(cls, value: str) -> Self:
|
||||
"""Parse a Roll from it's short representation, e.g. 2d6 or 1d20-2"""
|
||||
"""parse a Roll from its short representation, e.g. 2d6 or 1d20-2"""
|
||||
match = ROLL_PATTERN.fullmatch(value)
|
||||
if match is None:
|
||||
msg = f"expected {value!r} to match pattern {ROLL_PATTERN.pattern!r}"
|
||||
|
@ -37,21 +37,21 @@ class Roll:
|
|||
return cls(int(dice_count), int(sides), int(modifier) if modifier else None)
|
||||
|
||||
def modifier_str(self) -> str:
|
||||
"""Return the modifier as a string"""
|
||||
"""return the modifier as a string"""
|
||||
if self.modifier is None:
|
||||
return ""
|
||||
sign = "+" if self.modifier > 0 else ""
|
||||
return f"{sign}{self.modifier}"
|
||||
|
||||
def to_str(self) -> str:
|
||||
"""Return the short representation of a roll, e.g. 3d4 or 2d20+3"""
|
||||
"""return the short representation of a roll, e.g. 3d4 or 2d20+3"""
|
||||
return f"{self.dice_count}d{self.sides}{self.modifier_str()}"
|
||||
|
||||
def modify(self, modifier: int) -> Self:
|
||||
"""Return a new Roll with the given modifier"""
|
||||
"""return a new Roll with the given modifier"""
|
||||
return dataclasses.replace(self, modifier=modifier)
|
||||
|
||||
def throw(self) -> Throw:
|
||||
"""Throw the dice"""
|
||||
"""throw the dice"""
|
||||
throw = [random.randint(1, self.sides) for _ in range(self.dice_count)]
|
||||
return Throw(throw, self.modifier)
|
||||
return Throw(throw, self.sides, self.modifier)
|
||||
|
|
|
@ -1,12 +1,25 @@
|
|||
import dataclasses
|
||||
|
||||
D20_MAX = 20
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Throw:
|
||||
results: list[int]
|
||||
modifier: int | None
|
||||
sides: int
|
||||
modifier: int | None = None
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
"""Calculate the total of the throw, accounting for the modifier"""
|
||||
"""calculate the total of the throw, accounting for the modifier"""
|
||||
return sum(self.results) + (self.modifier or 0)
|
||||
|
||||
@property
|
||||
def is_critical_hit(self) -> bool:
|
||||
"""check if the throw is a 20 on a 1d20"""
|
||||
return self.sides == D20_MAX and self.results == [D20_MAX]
|
||||
|
||||
@property
|
||||
def is_critical_miss(self) -> bool:
|
||||
"""check if the throw is a 1 on a 1d20"""
|
||||
return self.sides == D20_MAX and self.results == [1]
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from roll.cli import cli
|
||||
from roll.roll import Roll
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner() -> CliRunner:
|
||||
return CliRunner()
|
||||
|
||||
|
||||
def test_cli_smoke(runner: CliRunner):
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
assert result.output.startswith("throwing 1d20:\n1:\t|")
|
||||
assert "total" in result.output
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["2d20", "4d6-2", "9d100+3"])
|
||||
def test_cli_roll(runner: CliRunner, value: str):
|
||||
result = runner.invoke(cli, [value])
|
||||
roll = Roll.from_str(value)
|
||||
assert result.exit_code == 0
|
||||
assert result.output.startswith(f"throwing {value}:\n1:\t|")
|
||||
assert len(result.output.splitlines()) >= roll.dice_count + 2
|
||||
assert "total" in result.output
|
||||
|
||||
|
||||
@pytest.mark.parametrize("advantage", ["advantage", "disadvantage"])
|
||||
def test_cli_advantage(runner: CliRunner, advantage: str):
|
||||
result = runner.invoke(cli, [advantage])
|
||||
assert result.exit_code == 0
|
||||
assert result.output.startswith(f"throwing 2d20 with {advantage}:\n1:\t|")
|
||||
assert "total" in result.output
|
|
@ -0,0 +1,38 @@
|
|||
import click
|
||||
import pytest
|
||||
|
||||
from roll.cli.roll_param import RollParam
|
||||
from roll.roll import Roll
|
||||
|
||||
|
||||
def test_roll_param_passthrough():
|
||||
roll = Roll()
|
||||
result = RollParam().convert(roll, None, None)
|
||||
assert result == roll
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["1d20", "2d20+3", "4d6-3"])
|
||||
def test_roll_param_valid(value):
|
||||
result = RollParam().convert(value, None, None)
|
||||
assert isinstance(result, Roll)
|
||||
assert result.to_str() == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["advantage", "disadvantage"])
|
||||
def test_roll_param_advantage(value):
|
||||
result = RollParam().convert(value, None, None)
|
||||
assert result == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["d90", "0d", "d-1", "aba", "000", "1d1d1d"])
|
||||
def test_roll_param_invalid(value):
|
||||
with pytest.raises(click.exceptions.BadParameter) as e:
|
||||
RollParam().convert(value, None, None)
|
||||
assert value in e.value.message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0d0", "0d20", "1d0", "1d1"])
|
||||
def test_roll_param_invalid_properties(value):
|
||||
with pytest.raises(click.exceptions.BadParameter) as e:
|
||||
RollParam().convert(value, None, None)
|
||||
assert "sides" in e.value.message or "dice" in e.value.message
|
|
@ -1,4 +1,4 @@
|
|||
import pytest # type: ignore (TODO: figure out why pyright can't import pytest)
|
||||
import pytest
|
||||
|
||||
from roll.roll import Roll
|
||||
|
||||
|
@ -11,7 +11,8 @@ def test_roll_validation():
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("roll", "expected"), [(Roll(1, 20), "1d20"), (Roll(2, 20, 3), "2d20+3"), (Roll(4, 6, -3), "4d6-3")]
|
||||
("roll", "expected"),
|
||||
[(Roll(1, 20), "1d20"), (Roll(2, 20, 3), "2d20+3"), (Roll(4, 6, -3), "4d6-3")],
|
||||
)
|
||||
def test_str_roundtrip(roll: Roll, expected: str):
|
||||
assert roll.to_str() == expected
|
||||
|
@ -29,7 +30,7 @@ def test_modify():
|
|||
modified_roll = roll.modify(3)
|
||||
assert modified_roll == Roll(2, 20, 3)
|
||||
assert roll == Roll(2, 20)
|
||||
assert modified_roll is not roll
|
||||
assert modified_roll is not roll and modified_roll != roll
|
||||
|
||||
|
||||
@pytest.mark.parametrize("n", list(range(1, 5)))
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
from roll.throw import Throw
|
||||
|
||||
|
||||
def test_throw():
|
||||
throw = Throw([1, 2, 3], sides=4)
|
||||
assert throw.total == 6
|
||||
assert not throw.is_critical_hit
|
||||
assert not throw.is_critical_miss
|
||||
|
||||
|
||||
def test_throw_with_modifier():
|
||||
throw = Throw([1, 2, 3], sides=4, modifier=5)
|
||||
assert throw.total == 11
|
||||
assert not throw.is_critical_hit
|
||||
assert not throw.is_critical_miss
|
||||
|
||||
|
||||
def test_critical_hit():
|
||||
throw = Throw([20], sides=20)
|
||||
assert throw.total == 20
|
||||
assert throw.is_critical_hit
|
||||
assert not throw.is_critical_miss
|
||||
|
||||
|
||||
def test_critical_miss():
|
||||
throw = Throw([1], sides=20)
|
||||
assert throw.total == 1
|
||||
assert not throw.is_critical_hit
|
||||
assert throw.is_critical_miss
|
Loading…
Reference in New Issue