rolly 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rolly-1.0.0/PKG-INFO +94 -0
- rolly-1.0.0/README.md +84 -0
- rolly-1.0.0/pyproject.toml +24 -0
- rolly-1.0.0/src/rolly/__init__.py +27 -0
- rolly-1.0.0/src/rolly/char.py +55 -0
- rolly-1.0.0/src/rolly/cli.py +73 -0
- rolly-1.0.0/src/rolly/output.py +39 -0
- rolly-1.0.0/src/rolly/py.typed +0 -0
- rolly-1.0.0/src/rolly/roll.py +147 -0
- rolly-1.0.0/src/rolly/roll_test.py +78 -0
- rolly-1.0.0/src/rolly/themes.py +141 -0
- rolly-1.0.0/src/rolly/themes_test.py +61 -0
rolly-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: rolly
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Roll dice on the command line because why not?
|
|
5
|
+
Author: George Lesica
|
|
6
|
+
Author-email: George Lesica <george@lesica.com>
|
|
7
|
+
Requires-Dist: click>=8.3.0
|
|
8
|
+
Requires-Python: >=3.14
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
# Rolly
|
|
14
|
+
|
|
15
|
+
Rolling dice the boring way.
|
|
16
|
+
|
|
17
|
+
## Introduction
|
|
18
|
+
|
|
19
|
+
A roll is expressed as a single string of text that contains a
|
|
20
|
+
sequence of instructions. Whitespace between instructions is entirely
|
|
21
|
+
ignored. There are different ways to express the same roll,
|
|
22
|
+
some being shorter than others. Below, we roll 3 D20s with
|
|
23
|
+
advantage in three different ways.
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
d20 d20 d20+
|
|
27
|
+
|
|
28
|
+
d20 x3+
|
|
29
|
+
|
|
30
|
+
d20 d20x2 +
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Instructions
|
|
34
|
+
|
|
35
|
+
The available instructions are listed below.
|
|
36
|
+
|
|
37
|
+
* A die to be rolled: `dN`, `N` is the number of sides
|
|
38
|
+
* Roll multiple dice: `xM`, `M` is the number of the previous die to roll
|
|
39
|
+
* Advantage on the roll: `+` anywhere in the string
|
|
40
|
+
* Disadvantage on the roll: `-` anywhere in the string
|
|
41
|
+
|
|
42
|
+
## Examples
|
|
43
|
+
|
|
44
|
+
So a number prefixed with `d` implies `x1` and a space between a number
|
|
45
|
+
and a count is ignored. Advantage is indicated by a `+` anywhere in the
|
|
46
|
+
string, disadvantage is a `-`.
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
d20x2 +
|
|
50
|
+
a d20 d20
|
|
51
|
+
d20x2-
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## CLI
|
|
55
|
+
|
|
56
|
+
The CLI is straightforward. Once the package is installed, it can be run
|
|
57
|
+
as a module:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
$ python -m rolly
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
This will show the help text. The primary command is `roll`, which takes
|
|
64
|
+
a string of instructions and displays the result.
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
$ python -m rolly roll d20x3
|
|
68
|
+
▄▄▄▄ ▄▄▄ ▄▄▄▄▄▄▄▄ ▄▄▄▄▄
|
|
69
|
+
██▀▀▀█ █▀██ ▀▀▀▀▀███ █▀▀▀▀██▄
|
|
70
|
+
██ ▄▄▄ ██ ▄██ ▄██
|
|
71
|
+
███▀▀██▄ ██ ██ █████
|
|
72
|
+
██ ██ ██ ██ ▀██
|
|
73
|
+
▀██▄▄██▀ ▄▄▄██▄▄▄ ██ █▄▄▄▄██▀
|
|
74
|
+
▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀ ▀▀▀▀▀
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The default uses ASCII art, but passing the `--plain` (`-p`) flag will
|
|
78
|
+
cause it to use plain text.
|
|
79
|
+
```
|
|
80
|
+
$ python -m rolly roll d20x3
|
|
81
|
+
6 17 3
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
It is also possible to sum the dice automatically using the `--add` (`-a`)
|
|
85
|
+
flag:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
$ python -m rolly roll -ap d20x3
|
|
89
|
+
16 12 7
|
|
90
|
+
= 35
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The ASCII art theme can be changed with the `--theme` (`-t`) option and
|
|
94
|
+
the available themes can be listed with the `themes` command.
|
rolly-1.0.0/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
# Rolly
|
|
4
|
+
|
|
5
|
+
Rolling dice the boring way.
|
|
6
|
+
|
|
7
|
+
## Introduction
|
|
8
|
+
|
|
9
|
+
A roll is expressed as a single string of text that contains a
|
|
10
|
+
sequence of instructions. Whitespace between instructions is entirely
|
|
11
|
+
ignored. There are different ways to express the same roll,
|
|
12
|
+
some being shorter than others. Below, we roll 3 D20s with
|
|
13
|
+
advantage in three different ways.
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
d20 d20 d20+
|
|
17
|
+
|
|
18
|
+
d20 x3+
|
|
19
|
+
|
|
20
|
+
d20 d20x2 +
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Instructions
|
|
24
|
+
|
|
25
|
+
The available instructions are listed below.
|
|
26
|
+
|
|
27
|
+
* A die to be rolled: `dN`, `N` is the number of sides
|
|
28
|
+
* Roll multiple dice: `xM`, `M` is the number of the previous die to roll
|
|
29
|
+
* Advantage on the roll: `+` anywhere in the string
|
|
30
|
+
* Disadvantage on the roll: `-` anywhere in the string
|
|
31
|
+
|
|
32
|
+
## Examples
|
|
33
|
+
|
|
34
|
+
So a number prefixed with `d` implies `x1` and a space between a number
|
|
35
|
+
and a count is ignored. Advantage is indicated by a `+` anywhere in the
|
|
36
|
+
string, disadvantage is a `-`.
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
d20x2 +
|
|
40
|
+
a d20 d20
|
|
41
|
+
d20x2-
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## CLI
|
|
45
|
+
|
|
46
|
+
The CLI is straightforward. Once the package is installed, it can be run
|
|
47
|
+
as a module:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
$ python -m rolly
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
This will show the help text. The primary command is `roll`, which takes
|
|
54
|
+
a string of instructions and displays the result.
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
$ python -m rolly roll d20x3
|
|
58
|
+
▄▄▄▄ ▄▄▄ ▄▄▄▄▄▄▄▄ ▄▄▄▄▄
|
|
59
|
+
██▀▀▀█ █▀██ ▀▀▀▀▀███ █▀▀▀▀██▄
|
|
60
|
+
██ ▄▄▄ ██ ▄██ ▄██
|
|
61
|
+
███▀▀██▄ ██ ██ █████
|
|
62
|
+
██ ██ ██ ██ ▀██
|
|
63
|
+
▀██▄▄██▀ ▄▄▄██▄▄▄ ██ █▄▄▄▄██▀
|
|
64
|
+
▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀ ▀▀▀▀▀
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The default uses ASCII art, but passing the `--plain` (`-p`) flag will
|
|
68
|
+
cause it to use plain text.
|
|
69
|
+
```
|
|
70
|
+
$ python -m rolly roll d20x3
|
|
71
|
+
6 17 3
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
It is also possible to sum the dice automatically using the `--add` (`-a`)
|
|
75
|
+
flag:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
$ python -m rolly roll -ap d20x3
|
|
79
|
+
16 12 7
|
|
80
|
+
= 35
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The ASCII art theme can be changed with the `--theme` (`-t`) option and
|
|
84
|
+
the available themes can be listed with the `themes` command.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "rolly"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Roll dice on the command line because why not?"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "George Lesica", email = "george@lesica.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.14"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"click>=8.3.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
rolly = "rolly:main"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.9.7,<0.10.0"]
|
|
19
|
+
build-backend = "uv_build"
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest>=8.4.2",
|
|
24
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from .roll import (
|
|
2
|
+
Roll as Roll,
|
|
3
|
+
)
|
|
4
|
+
|
|
5
|
+
from .output import (
|
|
6
|
+
Formatter as Formatter,
|
|
7
|
+
ArtFormatter as ArtFormatter,
|
|
8
|
+
TextFormatter as TextFormatter,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from .themes import (
|
|
12
|
+
Theme as Theme,
|
|
13
|
+
get_theme as get_theme,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# CLI interface for Rolly
|
|
18
|
+
|
|
19
|
+
def main():
|
|
20
|
+
import click
|
|
21
|
+
|
|
22
|
+
from .cli import entry
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
entry()
|
|
26
|
+
except RuntimeError as e:
|
|
27
|
+
click.echo(e)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Dict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class Character:
|
|
7
|
+
_data: Dict
|
|
8
|
+
|
|
9
|
+
_abil: Dict[str, int]
|
|
10
|
+
_save: Dict[str, int]
|
|
11
|
+
|
|
12
|
+
def __init__(self, path: str):
|
|
13
|
+
with open(path, 'rb') as f:
|
|
14
|
+
import tomllib
|
|
15
|
+
data = tomllib.load(f)
|
|
16
|
+
|
|
17
|
+
for sec, data in data:
|
|
18
|
+
match (sec, data):
|
|
19
|
+
case ('abilities', {
|
|
20
|
+
'str': int(),
|
|
21
|
+
'dex': int(),
|
|
22
|
+
'con': int(),
|
|
23
|
+
'int': int(),
|
|
24
|
+
'wis': int(),
|
|
25
|
+
'cha': int(),
|
|
26
|
+
}):
|
|
27
|
+
for attr in ('str', 'dex', 'con', 'int', 'wis', 'cha'):
|
|
28
|
+
self._abil[attr] = data[attr]
|
|
29
|
+
|
|
30
|
+
case ('saves', {
|
|
31
|
+
'str': int(),
|
|
32
|
+
'dex': int(),
|
|
33
|
+
'con': int(),
|
|
34
|
+
'int': int(),
|
|
35
|
+
'wis': int(),
|
|
36
|
+
'cha': int(),
|
|
37
|
+
}):
|
|
38
|
+
for attr in ('str', 'dex', 'con', 'int', 'wis', 'cha'):
|
|
39
|
+
self._save[attr] = data[attr]
|
|
40
|
+
|
|
41
|
+
case _:
|
|
42
|
+
raise RuntimeError(f'invalid character section: {sec}')
|
|
43
|
+
|
|
44
|
+
def has_attr(self, attr: str) -> bool:
|
|
45
|
+
return attr in self._abil or attr in self._save
|
|
46
|
+
|
|
47
|
+
def abil_val(self, attr: str) -> int:
|
|
48
|
+
if self.has_attr(attr):
|
|
49
|
+
return self._abil[attr]
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
def save_val(self, attr: str) -> int:
|
|
53
|
+
if self.has_attr(attr):
|
|
54
|
+
return self._save[attr]
|
|
55
|
+
return 0
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from typing import Tuple
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@click.group()
|
|
7
|
+
def entry():
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@entry.command()
|
|
12
|
+
def themes():
|
|
13
|
+
from .themes import get_themes
|
|
14
|
+
|
|
15
|
+
click.echo("Available themes:")
|
|
16
|
+
for name in get_themes():
|
|
17
|
+
click.echo(f" {name}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# TODO: The formatter (and theme) can be determined automatically
|
|
21
|
+
# Use click to decode the value passed (I think)
|
|
22
|
+
|
|
23
|
+
@entry.command()
|
|
24
|
+
@click.option("--plain", "-p", is_flag=True, flag_value=True, help="Display the coin flip as plain text")
|
|
25
|
+
@click.option("--theme", "-t", type=str, default='default', help="Name of the display theme to use")
|
|
26
|
+
def flip(plain: bool, theme: str):
|
|
27
|
+
from .roll import Roll
|
|
28
|
+
from .output import TextFormatter, ArtFormatter
|
|
29
|
+
from .themes import get_theme
|
|
30
|
+
|
|
31
|
+
if plain:
|
|
32
|
+
fmt = TextFormatter()
|
|
33
|
+
else:
|
|
34
|
+
t = get_theme(theme)
|
|
35
|
+
fmt = ArtFormatter(t)
|
|
36
|
+
|
|
37
|
+
letters = {1: 'H', 2: 'T'}
|
|
38
|
+
|
|
39
|
+
v = Roll("d2").roll()[0]
|
|
40
|
+
|
|
41
|
+
out = fmt.text(letters[v])
|
|
42
|
+
click.echo(out)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@entry.command()
|
|
46
|
+
@click.argument("s", type=str, nargs=-1, required=True)
|
|
47
|
+
@click.option("--add", "-a", is_flag=True, flag_value=True, help="Add up the dice and display a total")
|
|
48
|
+
@click.option("--plain", "-p", is_flag=True, flag_value=True, help="Display the roll as plain text")
|
|
49
|
+
@click.option("--theme", "-t", type=str, default='default', help="Name of the display theme to use")
|
|
50
|
+
def roll(s: Tuple[str, ...], add: bool, plain: bool, theme: str):
|
|
51
|
+
from .roll import Roll
|
|
52
|
+
from .output import TextFormatter, ArtFormatter
|
|
53
|
+
from .themes import get_theme
|
|
54
|
+
|
|
55
|
+
if plain:
|
|
56
|
+
fmt = TextFormatter()
|
|
57
|
+
else:
|
|
58
|
+
t = get_theme(theme)
|
|
59
|
+
fmt = ArtFormatter(t)
|
|
60
|
+
|
|
61
|
+
in_str = ' '.join(s)
|
|
62
|
+
|
|
63
|
+
r = Roll()
|
|
64
|
+
r.parse(in_str)
|
|
65
|
+
|
|
66
|
+
vs = r.roll()
|
|
67
|
+
|
|
68
|
+
out = ' '.join(str(v) for v in vs)
|
|
69
|
+
if add:
|
|
70
|
+
out += f'\n= {str(sum(vs))}'
|
|
71
|
+
|
|
72
|
+
fmt_out = fmt.text(out)
|
|
73
|
+
click.echo(fmt_out)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from typing import List, Protocol
|
|
2
|
+
|
|
3
|
+
from .themes import Theme
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Formatter(Protocol):
|
|
7
|
+
def nums(self, vs: List[int], sep: str = '') -> str:
|
|
8
|
+
raise NotImplementedError
|
|
9
|
+
|
|
10
|
+
def num(self, v: int) -> str:
|
|
11
|
+
raise NotImplementedError
|
|
12
|
+
|
|
13
|
+
def text(self, s: str) -> str:
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ArtFormatter(Formatter):
|
|
18
|
+
def __init__(self, theme: Theme):
|
|
19
|
+
self._theme = theme
|
|
20
|
+
|
|
21
|
+
def nums(self, vs: List[int], sep: str = '') -> str:
|
|
22
|
+
return self._theme.render(sep.join(str(v) for v in vs))
|
|
23
|
+
|
|
24
|
+
def num(self, v: int) -> str:
|
|
25
|
+
return self.nums([v])
|
|
26
|
+
|
|
27
|
+
def text(self, s: str) -> str:
|
|
28
|
+
return self._theme.render(s)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TextFormatter(Formatter):
|
|
32
|
+
def nums(self, vs: List[int], sep: str = '') -> str:
|
|
33
|
+
return sep.join(str(v) for v in vs)
|
|
34
|
+
|
|
35
|
+
def num(self, v: int) -> str:
|
|
36
|
+
return self.nums([v])
|
|
37
|
+
|
|
38
|
+
def text(self, s: str) -> str:
|
|
39
|
+
return s
|
|
File without changes
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from secrets import randbelow
|
|
3
|
+
from typing import List, Callable
|
|
4
|
+
|
|
5
|
+
from .char import Character
|
|
6
|
+
|
|
7
|
+
# A Roller just accepts a maximum value and returns a random
|
|
8
|
+
# value in the closed interval [1, max], to simulate rolling
|
|
9
|
+
# a die with max sides.
|
|
10
|
+
Roller = Callable[[int], int]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def default_roller(bound: int) -> int:
|
|
14
|
+
return randbelow(bound) + 1
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Roll:
|
|
19
|
+
_dice: List[int]
|
|
20
|
+
_roller: Roller
|
|
21
|
+
_adv: bool = False
|
|
22
|
+
_dis: bool = False
|
|
23
|
+
_bonus: int = 0
|
|
24
|
+
|
|
25
|
+
def __init__(self, roll: str = '', roller: Roller = default_roller):
|
|
26
|
+
self._dice = []
|
|
27
|
+
self._roller = roller
|
|
28
|
+
|
|
29
|
+
if roll != '':
|
|
30
|
+
self.parse(roll)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def bonus(self) -> int:
|
|
34
|
+
return self._bonus
|
|
35
|
+
|
|
36
|
+
def add_die(self, value: int):
|
|
37
|
+
self._dice.append(value)
|
|
38
|
+
|
|
39
|
+
def advantage(self, value: bool):
|
|
40
|
+
self._adv = value
|
|
41
|
+
|
|
42
|
+
def disadvantage(self, value: bool):
|
|
43
|
+
self._dis = value
|
|
44
|
+
|
|
45
|
+
def roll(self) -> List[int]:
|
|
46
|
+
one = self._roll()
|
|
47
|
+
|
|
48
|
+
if self._adv and not self._dis:
|
|
49
|
+
two = self._roll()
|
|
50
|
+
if sum(two) > sum(one):
|
|
51
|
+
return two
|
|
52
|
+
|
|
53
|
+
if self._dis and not self._adv:
|
|
54
|
+
two = self._roll()
|
|
55
|
+
if sum(two) < sum(one):
|
|
56
|
+
return two
|
|
57
|
+
|
|
58
|
+
return one
|
|
59
|
+
|
|
60
|
+
def _roll(self) -> List[int]:
|
|
61
|
+
values = []
|
|
62
|
+
|
|
63
|
+
for d in self._dice:
|
|
64
|
+
value = self._roller(d)
|
|
65
|
+
values.append(value)
|
|
66
|
+
|
|
67
|
+
return values
|
|
68
|
+
|
|
69
|
+
def reset(self):
|
|
70
|
+
self._dice.clear()
|
|
71
|
+
self._adv = False
|
|
72
|
+
self._dis = False
|
|
73
|
+
self._bonus = 0
|
|
74
|
+
|
|
75
|
+
def parse(self, roll: str, character: Character | None = None):
|
|
76
|
+
self.reset()
|
|
77
|
+
|
|
78
|
+
# Burn whitespace from the ends to handle line endings
|
|
79
|
+
roll = roll.strip()
|
|
80
|
+
|
|
81
|
+
attr = ''
|
|
82
|
+
save = False
|
|
83
|
+
|
|
84
|
+
while len(roll) > 0:
|
|
85
|
+
if roll.startswith('d'):
|
|
86
|
+
# Reading a die
|
|
87
|
+
roll = roll[1:]
|
|
88
|
+
|
|
89
|
+
value = 0
|
|
90
|
+
while len(roll) > 0 and roll[0] in '0123456789':
|
|
91
|
+
value = value * 10 + int(roll[0])
|
|
92
|
+
roll = roll[1:]
|
|
93
|
+
|
|
94
|
+
self._dice.append(value)
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
if roll.startswith('x'):
|
|
98
|
+
# Reading a count for the previous die
|
|
99
|
+
roll = roll[1:]
|
|
100
|
+
|
|
101
|
+
if len(self._dice) == 0:
|
|
102
|
+
raise RuntimeError('invalid syntax: count found before dice')
|
|
103
|
+
|
|
104
|
+
value = 0
|
|
105
|
+
while len(roll) > 0 and roll[0] in '0123456789':
|
|
106
|
+
value = value * 10 + int(roll[0])
|
|
107
|
+
roll = roll[1:]
|
|
108
|
+
|
|
109
|
+
self._dice.extend([self._dice[-1]] * (value - 1))
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
if roll.startswith('+'):
|
|
113
|
+
# Advantage indicator
|
|
114
|
+
roll = roll[1:]
|
|
115
|
+
|
|
116
|
+
self._adv = True
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
if roll.startswith('-'):
|
|
120
|
+
# Disadvantage indicator
|
|
121
|
+
self._dis = True
|
|
122
|
+
roll = roll[1:]
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
if roll.startswith('save'):
|
|
126
|
+
save = True
|
|
127
|
+
roll = roll[4:]
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
if character is not None and len(roll) >= 3 and character.has_attr(roll[:3]):
|
|
131
|
+
attr = roll[:3]
|
|
132
|
+
roll = roll[3:]
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
if roll.startswith(' '):
|
|
136
|
+
# Whitespace is ignored
|
|
137
|
+
roll = roll[1:]
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# If we didn't hit anything, then we never will, so we bail
|
|
141
|
+
raise RuntimeError(f'invalid syntax: unknown directive ({roll})')
|
|
142
|
+
|
|
143
|
+
if attr != '':
|
|
144
|
+
if save:
|
|
145
|
+
self._bonus += character.save_val(attr)
|
|
146
|
+
else:
|
|
147
|
+
self._bonus += character.abil_val(attr)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
from .roll import Roll
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_roll():
|
|
8
|
+
r = Roll()
|
|
9
|
+
r._dice.extend([2, 2, 2])
|
|
10
|
+
r1 = r.roll()
|
|
11
|
+
|
|
12
|
+
assert len(r1) == 3
|
|
13
|
+
assert r1[0] == 1 or r1[0] == 2
|
|
14
|
+
assert r1[1] == 1 or r1[1] == 2
|
|
15
|
+
assert r1[2] == 1 or r1[2] == 2
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class FakeRoller:
|
|
20
|
+
seq: List[int]
|
|
21
|
+
index: int = -1
|
|
22
|
+
|
|
23
|
+
def roll(self, bound: int) -> int:
|
|
24
|
+
self.index += 1
|
|
25
|
+
|
|
26
|
+
value = self.seq[self.index]
|
|
27
|
+
if value >= bound:
|
|
28
|
+
raise RuntimeError(f"Value {value} exceeds bound {bound}")
|
|
29
|
+
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_roll_adv():
|
|
34
|
+
r = Roll(roller=FakeRoller([1, 2]).roll)
|
|
35
|
+
r._dice.extend([20])
|
|
36
|
+
r._adv = True
|
|
37
|
+
vs = r.roll()
|
|
38
|
+
|
|
39
|
+
assert len(vs) == 1
|
|
40
|
+
assert vs[0] == 2
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_roll_dis():
|
|
44
|
+
r = Roll(roller=FakeRoller([2, 1]).roll)
|
|
45
|
+
r._dice.extend([20])
|
|
46
|
+
r._dis = True
|
|
47
|
+
vs = r.roll()
|
|
48
|
+
|
|
49
|
+
assert len(vs) == 1
|
|
50
|
+
assert vs[0] == 1
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_parse_dice():
|
|
54
|
+
r = Roll()
|
|
55
|
+
r.parse('d6 d10 d20 d14')
|
|
56
|
+
|
|
57
|
+
assert r._dice == [6, 10, 20, 14]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_parse_count():
|
|
61
|
+
r = Roll()
|
|
62
|
+
r.parse('d6x3')
|
|
63
|
+
|
|
64
|
+
assert r._dice == [6, 6, 6]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_parse_adv():
|
|
68
|
+
r = Roll()
|
|
69
|
+
r.parse('d20+')
|
|
70
|
+
|
|
71
|
+
assert r._adv
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_parse_dis():
|
|
75
|
+
r = Roll()
|
|
76
|
+
r.parse('d20-')
|
|
77
|
+
|
|
78
|
+
assert r._dis
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from typing import List, Dict, Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Theme:
|
|
5
|
+
_chars: str = 'HT0123456789='
|
|
6
|
+
|
|
7
|
+
def __init__(self, templ: str, widths: Optional[Dict[str, int]] = None, padding: int = 0, width: int = 0, space_width: int = 2):
|
|
8
|
+
self._templ: List[str] = templ.split('\n')[1:-1]
|
|
9
|
+
|
|
10
|
+
self._height = len(self._templ)
|
|
11
|
+
if self._height == 0:
|
|
12
|
+
raise RuntimeError('invalid theme: no template specified')
|
|
13
|
+
|
|
14
|
+
if widths is None:
|
|
15
|
+
self._widths = {}
|
|
16
|
+
else:
|
|
17
|
+
self._widths = widths
|
|
18
|
+
|
|
19
|
+
self._pad = padding
|
|
20
|
+
self._width = width
|
|
21
|
+
self._space_width = space_width
|
|
22
|
+
|
|
23
|
+
self._validate()
|
|
24
|
+
|
|
25
|
+
def render(self, s: str) -> str:
|
|
26
|
+
out_lines = [''] * self._height
|
|
27
|
+
out_str = ''
|
|
28
|
+
|
|
29
|
+
# A leading or trailing newline makes things weird
|
|
30
|
+
# and isn't necessary anyway, so drop them
|
|
31
|
+
in_str = s.strip('\n')
|
|
32
|
+
|
|
33
|
+
for c in in_str:
|
|
34
|
+
if c == '\n':
|
|
35
|
+
out_str += '\n'.join(out_lines) + '\n'
|
|
36
|
+
out_lines = [''] * self._height
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
if c == ' ':
|
|
40
|
+
for line_index in range(self._height):
|
|
41
|
+
out_lines[line_index] += ' ' * self._space_width
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
if c not in self._chars:
|
|
45
|
+
raise RuntimeError(f'invalid string: character {c} is not supported')
|
|
46
|
+
|
|
47
|
+
offset = self._get_offset(c)
|
|
48
|
+
width = self._get_width(c)
|
|
49
|
+
for line_index in range(self._height):
|
|
50
|
+
out_lines[line_index] += self._templ[line_index][offset:offset + width].ljust(width)
|
|
51
|
+
|
|
52
|
+
return out_str + '\n'.join(out_lines)
|
|
53
|
+
|
|
54
|
+
def _validate(self):
|
|
55
|
+
self.render(self._chars)
|
|
56
|
+
|
|
57
|
+
def _get_offset(self, c: str):
|
|
58
|
+
offset = 0
|
|
59
|
+
ci = self._chars.index(c)
|
|
60
|
+
|
|
61
|
+
# Account for everything before the character we want
|
|
62
|
+
for i in range(ci):
|
|
63
|
+
offset += self._get_width(self._chars[i])
|
|
64
|
+
offset += self._pad
|
|
65
|
+
|
|
66
|
+
return offset
|
|
67
|
+
|
|
68
|
+
def _get_width(self, c: str) -> int:
|
|
69
|
+
w = 0
|
|
70
|
+
if c in self._widths:
|
|
71
|
+
w = self._widths[c]
|
|
72
|
+
|
|
73
|
+
if w == 0:
|
|
74
|
+
if self._width == 0:
|
|
75
|
+
raise RuntimeError(f'invalid theme: no width specified for char {c}')
|
|
76
|
+
w = self._width
|
|
77
|
+
|
|
78
|
+
return w
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
DEFAULT = r'''
|
|
82
|
+
▄▄ ▄▄ ▄▄▄▄▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄▄ ▄▄▄▄▄ ▄▄▄ ▄▄▄▄▄▄▄ ▄▄▄▄ ▄▄▄▄▄▄▄▄ ▄▄▄▄ ▄▄▄▄
|
|
83
|
+
██ ██ ▀▀▀██▀▀▀ ██▀▀██ █▀██ █▀▀▀▀██▄ █▀▀▀▀██▄ ▄███ ██▀▀▀▀▀ ██▀▀▀█ ▀▀▀▀▀███ ▄██▀▀██▄ ▄██▀▀██▄
|
|
84
|
+
██ ██ ██ ██ ██ ██ ██ ▄██ █▀ ██ ██▄▄▄▄ ██ ▄▄▄ ▄██ ██▄ ▄██ ██ ██ ▄▄▄▄▄▄▄▄
|
|
85
|
+
████████ ██ ██ ██ ██ ██ ▄█▀ █████ ▄█▀ ██ █▀▀▀▀██▄ ███▀▀██▄ ██ ██████ ▀██▄▄███ ▀▀▀▀▀▀▀▀
|
|
86
|
+
██ ██ ██ ██ ██ ██ ▄█▀ ▀██ ████████ ██ ██ ██ ██ ██▀ ▀██ ▀▀▀ ██ ▄▄▄▄▄▄▄▄
|
|
87
|
+
██ ██ ██ ██▄▄██ ▄▄▄██▄▄▄ ▄██▄▄▄▄▄ █▄▄▄▄██▀ ██ █▄▄▄▄██▀ ▀██▄▄██▀ ██ ▀██▄▄██▀ █▄▄▄██ ▀▀▀▀▀▀▀▀
|
|
88
|
+
▀▀ ▀▀ ▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀ ▀▀ ▀▀▀▀▀ ▀▀▀▀ ▀▀ ▀▀▀▀ ▀▀▀▀
|
|
89
|
+
'''
|
|
90
|
+
|
|
91
|
+
ALPHA = r'''
|
|
92
|
+
H H TTTTTT 000 11 22 333 4 4 5555 6 77777 888 9999
|
|
93
|
+
H H TT 0 00 111 2 2 3 4 4 5 6 7 8 8 9 9 ===
|
|
94
|
+
HHHH TT 0 0 0 11 2 33 4444 555 6666 7 888 9999
|
|
95
|
+
H H TT 00 0 11 2 3 4 5 6 6 7 8 8 9 ===
|
|
96
|
+
H H TT 000 1111 2222 333 4 555 666 7 888 9
|
|
97
|
+
'''
|
|
98
|
+
|
|
99
|
+
OUTLINE = r'''
|
|
100
|
+
_ _ _______ ___ __ ___ ____ _ _ _____ __ ______ ___ ___
|
|
101
|
+
| | | | |__ __| / _ \ /_ | |__ \ |___ \ | || | | ____| / / |____ | / _ \ / _ \ ______
|
|
102
|
+
| |__| | | | | | | | | | ) | __) | | || |_ | |__ / /_ / / | (_) | | (_) | |______|
|
|
103
|
+
| __ | | | | | | | | | / / |__ < |__ _| |___ \ | '_ \ / / > _ < \__, | ______
|
|
104
|
+
| | | | | | | |_| | | | / /_ ___) | | | ___) | | (_) | / / | (_) | / / |______|
|
|
105
|
+
|_| |_| |_| \___/ |_| |____| |____/ |_| |____/ \___/ /_/ \___/ /_/
|
|
106
|
+
'''
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
ALL_THEMES: Dict[str, Theme] = {
|
|
110
|
+
'default': Theme(DEFAULT, width=10, space_width=8),
|
|
111
|
+
'alpha': Theme(ALPHA, {
|
|
112
|
+
'T': 8,
|
|
113
|
+
'0': 7,
|
|
114
|
+
'6': 7,
|
|
115
|
+
'7': 7,
|
|
116
|
+
'8': 7,
|
|
117
|
+
'9': 7,
|
|
118
|
+
'=': 5,
|
|
119
|
+
}, width=6, padding=3, space_width=5),
|
|
120
|
+
'outline': Theme(OUTLINE, {
|
|
121
|
+
'H': 8,
|
|
122
|
+
'T': 9,
|
|
123
|
+
'1': 4,
|
|
124
|
+
'2': 6,
|
|
125
|
+
'4': 8,
|
|
126
|
+
'7': 8,
|
|
127
|
+
'=': 8,
|
|
128
|
+
}, width=7, padding=1, space_width=3)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_themes() -> List[str]:
|
|
133
|
+
return list(ALL_THEMES.keys())
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def get_theme(name: str) -> Theme:
|
|
137
|
+
key = name.strip().lower()
|
|
138
|
+
if key in ALL_THEMES:
|
|
139
|
+
return ALL_THEMES[key]
|
|
140
|
+
|
|
141
|
+
raise RuntimeError(f'invalid theme name: {name}')
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from .themes import Theme
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
TEST_THEME = r'''
|
|
5
|
+
H T 0 1 2 3 4 5 6 7 8 9 =
|
|
6
|
+
H T 0 1 2 3 4 5 6 7 8 9 =
|
|
7
|
+
'''
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_first_letter():
|
|
11
|
+
t = Theme(TEST_THEME, width=1, padding=1)
|
|
12
|
+
assert t.render('H') == 'H\nH'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_last_letter():
|
|
16
|
+
t = Theme(TEST_THEME, width=1, padding=1)
|
|
17
|
+
assert t.render('=') == '=\n='
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_middle_letter():
|
|
21
|
+
t = Theme(TEST_THEME, width=1, padding=1)
|
|
22
|
+
assert t.render('5') == '5\n5'
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_space():
|
|
26
|
+
t = Theme(TEST_THEME, width=1, padding=1, space_width=1)
|
|
27
|
+
assert t.render(' ') == ' \n '
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_multiple_letters():
|
|
31
|
+
t = Theme(TEST_THEME, width=1, padding=1, space_width=2)
|
|
32
|
+
assert t.render('H T') == 'H T\nH T'
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
TEST_THEME_WIDE = r'''
|
|
36
|
+
HH TT 00 11 22 33 44 55 66 77 88 99 ==
|
|
37
|
+
HH TT 00 11 22 33 44 55 66 77 88 99 ==
|
|
38
|
+
'''
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_wide_theme():
|
|
42
|
+
t = Theme(TEST_THEME_WIDE, width=2, padding=1, space_width=3)
|
|
43
|
+
assert t.render('H') == 'HH\nHH'
|
|
44
|
+
assert t.render('=') == '==\n=='
|
|
45
|
+
assert t.render('5') == '55\n55'
|
|
46
|
+
assert t.render(' ') == ' \n '
|
|
47
|
+
assert t.render('H T') == 'HH TT\nHH TT'
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
TEST_THEME_VARIED = r'''
|
|
51
|
+
HHH TT 00 11 22 33 44 555 66 77 88 99 ===
|
|
52
|
+
HHH TT 00 11 22 33 44 555 66 77 88 99 ===
|
|
53
|
+
'''
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_wide_varied():
|
|
57
|
+
t = Theme(TEST_THEME_VARIED, widths={'H': 3, '5': 3, '=': 3}, width=2, padding=1)
|
|
58
|
+
assert t.render('H') == 'HHH\nHHH'
|
|
59
|
+
assert t.render('=') == '===\n==='
|
|
60
|
+
assert t.render('5') == '555\n555'
|
|
61
|
+
assert t.render('H T') == 'HHH TT\nHHH TT'
|