Compare commits

...

3 Commits

Author SHA1 Message Date
mat ess 18224b1ea7 Update cli 2023-08-13 14:58:25 -04:00
mat ess f192f1f1f3 Update roll data structures 2023-08-13 14:53:51 -04:00
mat ess e985ca230c Housekeeping 2023-08-13 14:40:33 -04:00
15 changed files with 281 additions and 67 deletions

1
.gitignore vendored
View File

@ -5,3 +5,4 @@ __pycache__
.*_cache .*_cache
.pre-commit-config.yaml .pre-commit-config.yaml
.vscode .vscode
.coverage

View File

@ -26,5 +26,7 @@ total | 1
## todo ## todo
- [ ] roll with (dis)advantage - [x] roll with (dis)advantage
- [ ] interactive rolling mode - [ ] interactive rolling mode
- [x] print criticals
- [ ] use property testing

View File

@ -74,11 +74,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1691579266, "lastModified": 1691931346,
"narHash": "sha256-Ue2iaxU5VxwjXX6bWv/ElOl35O4+Rk630jBjMqQDRRs=", "narHash": "sha256-QLK0wLyJEnLU37CTBNnZBY6mNily7w8zeb34XyhSGh0=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "a14013769370b021e23200e7199d8cfaeb97098a", "rev": "8f38b58c34bb759ae83a0581120fe2d7fa6bb539",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -133,11 +133,11 @@
"nixpkgs-stable": "nixpkgs-stable" "nixpkgs-stable": "nixpkgs-stable"
}, },
"locked": { "locked": {
"lastModified": 1691397944, "lastModified": 1691747570,
"narHash": "sha256-4fa4bX3kPYKpEssgrFRxRCPVXczidArDeSWaUMSzQAU=", "narHash": "sha256-J3fnIwJtHVQ0tK2JMBv4oAmII+1mCdXdpeCxtIsrL2A=",
"owner": "cachix", "owner": "cachix",
"repo": "pre-commit-hooks.nix", "repo": "pre-commit-hooks.nix",
"rev": "e5588ddffd4c3578547a86ef40ec9a6fbdae2986", "rev": "c5ac3aa3324bd8aebe8622a3fc92eeb3975d317a",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@ -26,8 +26,8 @@
pre-commit.settings.hooks = { pre-commit.settings.hooks = {
autoflake.enable = true; autoflake.enable = true;
black.enable = true; black.enable = true;
# mypy.enable = true;
ruff.enable = true; ruff.enable = true;
pyright.enable = true;
}; };
packages.default = package; packages.default = package;
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {

30
justfile Normal file
View File

@ -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

View File

@ -30,7 +30,7 @@ Issues = "https://git.mat.services/mat/roll/issues"
Source = "https://git.mat.services/mat/roll" Source = "https://git.mat.services/mat/roll"
[project.scripts] [project.scripts]
roll = "roll.cli:roll" roll = "roll.cli:cli"
[tool.hatch.version] [tool.hatch.version]
path = "roll/__about__.py" path = "roll/__about__.py"
@ -47,7 +47,7 @@ cov-report = [
"- coverage combine", "- coverage combine",
"coverage report", "coverage report",
] ]
cov = [ check = [
"test-cov", "test-cov",
"cov-report", "cov-report",
] ]
@ -55,15 +55,18 @@ cov = [
[[tool.hatch.envs.all.matrix]] [[tool.hatch.envs.all.matrix]]
python = ["3.11"] 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] [tool.hatch.envs.lint]
detached = true detached = true
dependencies = [ dependencies = [
"black>=23.1.0", "black>=23.1.0",
"pyright>=1.1.319",
"ruff>=0.0.243", "ruff>=0.0.243",
] ]
[tool.hatch.envs.lint.scripts] [tool.hatch.envs.lint.scripts]
typing = "pyright --project pyproject.toml {args:roll tests}"
style = [ style = [
"ruff {args:.}", "ruff {args:.}",
"black --check --diff {args:.}", "black --check --diff {args:.}",
@ -73,19 +76,18 @@ fmt = [
"ruff --fix {args:.}", "ruff --fix {args:.}",
"style", "style",
] ]
all = [ check = [
"style", "style",
"typing",
] ]
[tool.black] [tool.black]
target-version = ["py311"] target-version = ["py311"]
line-length = 120 line-length = 100
skip-string-normalization = true # skip-string-normalization = true
[tool.ruff] [tool.ruff]
target-version = "py311" target-version = "py311"
line-length = 120 line-length = 100
select = [ select = [
"A", "A",
"ARG", "ARG",
@ -153,8 +155,21 @@ roll = ["roll", "*/roll/roll"]
tests = ["tests", "*/roll/tests"] tests = ["tests", "*/roll/tests"]
[tool.coverage.report] [tool.coverage.report]
show_missing = true
skip_empty = true
fail_under = 100
exclude_lines = [ exclude_lines = [
"no cov", "no cov",
"if __name__ == .__main__.:", "if __name__ == .__main__.:",
"if TYPE_CHECKING:", "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

View File

@ -1,6 +1,6 @@
if __name__ == "__main__":
import sys import sys
if __name__ == "__main__": from roll.cli import cli
from roll.cli import roll
sys.exit(roll()) sys.exit(cli())

View File

@ -1,50 +1,92 @@
from typing import Literal
import click import click
from roll.__about__ import __version__ from roll.__about__ import __version__
from roll.cli.roll_param import ROLL from roll.cli.roll_param import ROLL
from roll.roll import Roll from roll.roll import Roll
from roll.throw import Throw
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
@click.group( @click.group(context_settings=CONTEXT_SETTINGS, invoke_without_command=True)
context_settings={"help_option_names": ["-h", "--help"]},
invoke_without_command=True,
)
@click.version_option(version=__version__, prog_name="roll") @click.version_option(version=__version__, prog_name="roll")
@click.argument("rolls", nargs=-1, type=ROLL) @click.argument("roll", type=ROLL, default="1d20")
def roll(rolls: list[Roll]): def cli(roll: Roll | Literal["advantage"] | Literal["disadvantage"]) -> None:
"""Throw each roll specified in ROLLS and print the results. """throw a ROLL and print the results
Rolls are specified as ROLL is specified as
DdS[(+|-)M] 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 \b
$ roll 2d20+3 3d6-1 $ roll 2d20+3
rolling 2d20+3: rolling 2d20+3:
1: | 2 1: | 2
2: | 13 2: | 13
mod: +3 mod: +3
total: 18 total: 18
\b \b
rolling 3d6-1: $ roll
1: | 3 rolling 1d20:
2: | 3 1: | 11
3: | 1 total: 11
mod: -1 \b
total: 6 $ 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: if isinstance(roll, str):
click.echo() throw = _advantage(roll)
click.echo(f"rolling {roll.to_str()}:") 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() throw = roll.throw()
for i, result in enumerate(throw.results): for i, result in enumerate(throw.results, start=1):
click.echo(f"{i + 1}:\t| {result: >3}") click.echo(f"{i}:\t|{result: >4}")
if roll.modifier: if roll.modifier is not None:
mod = roll.modifier_str() click.echo(f"mod:\t{roll.modifier_str(): >5}")
click.echo(f"mod:\t {mod: >4}") return throw
click.echo(f"total:\t {throw.total: >3}")

View File

@ -1,3 +1,5 @@
from typing import Literal
import click import click
from roll.roll import Roll from roll.roll import Roll
@ -6,14 +8,20 @@ from roll.roll import Roll
class RollParam(click.ParamType): class RollParam(click.ParamType):
name = "roll" name = "roll"
def convert(self, value: str | Roll, param: click.Parameter | None, ctx: click.Context | None) -> Roll: def convert(
"""Parse a Roll from a command line string.""" 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): if isinstance(value, Roll):
return value return value
elif "advantage".startswith(value):
return "advantage"
elif "disadvantage".startswith(value):
return "disadvantage"
try: try:
return Roll.from_str(value) return Roll.from_str(value)
except Exception as e: except Exception as e:
self.fail(f"invalid roll: {value!r}, caused by {e}", param, ctx) self.fail(f"{e}", param, ctx)
ROLL = RollParam() ROLL = RollParam()

View File

@ -12,13 +12,13 @@ ROLL_PATTERN = re.compile(r"(\d+)d(\d+)([+-]\d+)?")
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class Roll: class Roll:
"""A roll of one or more dice""" """a roll of one or more dice"""
dice_count: int dice_count: int = 1
sides: int sides: int = 20
modifier: int | None = None modifier: int | None = None
def __post_init__(self): def __post_init__(self) -> None:
if self.dice_count < 1: if self.dice_count < 1:
msg = "dice must be greater than 0" msg = "dice must be greater than 0"
raise ValueError(msg) raise ValueError(msg)
@ -28,7 +28,7 @@ class Roll:
@classmethod @classmethod
def from_str(cls, value: str) -> Self: 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) match = ROLL_PATTERN.fullmatch(value)
if match is None: if match is None:
msg = f"expected {value!r} to match pattern {ROLL_PATTERN.pattern!r}" 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) return cls(int(dice_count), int(sides), int(modifier) if modifier else None)
def modifier_str(self) -> str: def modifier_str(self) -> str:
"""Return the modifier as a string""" """return the modifier as a string"""
if self.modifier is None: if self.modifier is None:
return "" return ""
sign = "+" if self.modifier > 0 else "" sign = "+" if self.modifier > 0 else ""
return f"{sign}{self.modifier}" return f"{sign}{self.modifier}"
def to_str(self) -> str: 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()}" return f"{self.dice_count}d{self.sides}{self.modifier_str()}"
def modify(self, modifier: int) -> Self: 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) return dataclasses.replace(self, modifier=modifier)
def throw(self) -> Throw: def throw(self) -> Throw:
"""Throw the dice""" """throw the dice"""
throw = [random.randint(1, self.sides) for _ in range(self.dice_count)] throw = [random.randint(1, self.sides) for _ in range(self.dice_count)]
return Throw(throw, self.modifier) return Throw(throw, self.sides, self.modifier)

View File

@ -1,12 +1,25 @@
import dataclasses import dataclasses
D20_MAX = 20
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class Throw: class Throw:
results: list[int] results: list[int]
modifier: int | None sides: int
modifier: int | None = None
@property @property
def total(self) -> int: 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) 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]

35
tests/cli/cli_test.py Normal file
View File

@ -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

View File

@ -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

View File

@ -1,4 +1,4 @@
import pytest # type: ignore (TODO: figure out why pyright can't import pytest) import pytest
from roll.roll import Roll from roll.roll import Roll
@ -11,7 +11,8 @@ def test_roll_validation():
@pytest.mark.parametrize( @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): def test_str_roundtrip(roll: Roll, expected: str):
assert roll.to_str() == expected assert roll.to_str() == expected
@ -29,7 +30,7 @@ def test_modify():
modified_roll = roll.modify(3) modified_roll = roll.modify(3)
assert modified_roll == Roll(2, 20, 3) assert modified_roll == Roll(2, 20, 3)
assert roll == Roll(2, 20) 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))) @pytest.mark.parametrize("n", list(range(1, 5)))

29
tests/throw_test.py Normal file
View File

@ -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